Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
size"sm" | "default""default"Track height on SegmentedTabsList: 28px or 32px.
shape"pill" | "rounded""pill"Corner shape of the track and indicator on SegmentedTabsList.
countnumber | stringOptional count after the label on SegmentedTabsTrigger.
value, defaultValue, onValueChangeradix Tabs propsControlled 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