Segmented tabs
Tabs on a pill track with one indicator that slides to the active tab.
npx shadcn@latest add https://ui.goodtabs.co/r/segmented-tabs.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/segmented-tabs
Three services, all healthy. Last deploy 14 minutes ago.
Three tabs with content. Arrow keys move between them.
With counts
12 open issues, 4 assigned to you.
A count after each label, on the rounded shape.
Small, in a card header
Requests
283,114
Up 2% on the week before
The sm size as a range switch next to a title.
Usage
import {
SegmentedTabs,
SegmentedTabsContent,
SegmentedTabsList,
SegmentedTabsTrigger,
} from "@/components/ui/segmented-tabs"
<SegmentedTabs defaultValue="overview">
<SegmentedTabsList>
<SegmentedTabsTrigger value="overview">Overview</SegmentedTabsTrigger>
<SegmentedTabsTrigger value="activity" count={3}>Activity</SegmentedTabsTrigger>
</SegmentedTabsList>
<SegmentedTabsContent value="overview">...</SegmentedTabsContent>
<SegmentedTabsContent value="activity">...</SegmentedTabsContent>
</SegmentedTabs>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "default" | "default" | Track height on SegmentedTabsList: 28px or 32px. |
| shape | "pill" | "rounded" | "pill" | Corner shape of the track and indicator on SegmentedTabsList. |
| count | number | string | Optional count after the label on SegmentedTabsTrigger. | |
| value, defaultValue, onValueChange | radix Tabs props | Controlled or uncontrolled, on SegmentedTabs. |
Source
segmented-tabs/segmented-tabs.tsx
"use client";
import * as React from "react";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
type Size = "sm" | "default";
type Shape = "pill" | "rounded";
const ListContext = React.createContext<{ size: Size }>({ size: "default" });
/** Root. Controlled or uncontrolled through radix: value, defaultValue, onValueChange. */
function SegmentedTabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="segmented-tabs" className={cn("flex flex-col gap-3", className)} {...props} />;
}
type ListProps = React.ComponentProps<typeof TabsPrimitive.List> & {
/** Height of the track. sm is 28px, default is 32px. */
size?: Size;
/** Fully round or softly rounded corners. */
shape?: Shape;
};
/**
* The track. Measures the active trigger and slides one indicator under it.
* The indicator is a sibling, so triggers stay plain buttons for radix and screen readers.
*/
function SegmentedTabsList({ className, size = "default", shape = "pill", children, ...props }: ListProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [indicator, setIndicator] = React.useState<{ x: number; width: number } | null>(null);
React.useEffect(() => {
const list = ref.current;
if (!list) return;
const measure = () => {
const active = list.querySelector<HTMLElement>('[role="tab"][data-state="active"]');
if (!active) return setIndicator(null);
setIndicator({ x: active.offsetLeft, width: active.offsetWidth });
};
measure();
const resize = new ResizeObserver(measure);
resize.observe(list);
for (const tab of list.querySelectorAll<HTMLElement>('[role="tab"]')) resize.observe(tab);
// data-state flips on the trigger when the value changes; watch it instead of threading value through.
const mutation = new MutationObserver(measure);
mutation.observe(list, { attributes: true, subtree: true, attributeFilter: ["data-state"] });
return () => {
resize.disconnect();
mutation.disconnect();
};
}, []);
const radius = shape === "pill" ? "rounded-full" : "rounded-md";
return (
<ListContext.Provider value={{ size }}>
<TabsPrimitive.List
ref={ref}
data-slot="segmented-tabs-list"
data-size={size}
className={cn(
"relative isolate inline-flex w-fit max-w-full items-center gap-px overflow-x-auto bg-muted p-0.5 text-foreground/70",
size === "sm" ? "h-7" : "h-8",
radius,
className,
)}
{...props}
>
{indicator && (
<span
aria-hidden="true"
data-slot="segmented-tabs-indicator"
className={cn(
"absolute inset-y-0.5 left-0 -z-10 bg-background shadow-xs transition-[transform,width] duration-150 ease-out motion-reduce:transition-none",
radius,
)}
style={{ width: indicator.width, transform: `translateX(${indicator.x}px)` }}
/>
)}
{children}
</TabsPrimitive.List>
</ListContext.Provider>
);
}
type TriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger> & {
/** Optional count shown after the label. */
count?: number | string;
};
function SegmentedTabsTrigger({ className, count, children, ...props }: TriggerProps) {
const { size } = React.useContext(ListContext);
return (
<TabsPrimitive.Trigger
data-slot="segmented-tabs-trigger"
className={cn(
"inline-flex h-full shrink-0 items-center justify-center gap-1.5 rounded-full px-3 font-medium whitespace-nowrap transition-colors duration-100 ease-out outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground",
size === "sm" ? "text-xs" : "text-sm",
className,
)}
{...props}
>
{children}
{count !== undefined && (
<span className="font-mono text-xs font-normal text-foreground/70" aria-label={`${count} items`}>
{count}
</span>
)}
</TabsPrimitive.Trigger>
);
}
function SegmentedTabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="segmented-tabs-content"
className={cn("outline-none focus-visible:ring-2 focus-visible:ring-ring/50", className)}
{...props}
/>
);
}
export { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger, SegmentedTabsContent };
For AI agents
Hand your agent this page, the markdown at /components/segmented-tabs/prompt.md, or the Copy AI prompt button above.
When to use
- Switching between two to five views of the same thing: ranges, statuses, layouts
- Inside a card header or toolbar where a full tab bar would be too heavy
- When the active choice should read at a glance without a page change
When not to use
- For page-level navigation between routes, use links or the navbar
- For more than five options or long labels, use a select or a sidebar
- For independent on and off switches, use toggles
Agent notes
- It is radix Tabs underneath: value, defaultValue and onValueChange work as in shadcn tabs
- The indicator is measured with ResizeObserver, so triggers can have any width and the list can scroll horizontally
- size and shape live on SegmentedTabsList, not on the root
- Put the list in a flex container with w-fit if you do not want it to stretch