Tabs

Tabs split related content into panels and show one at a time. The row of tabs stays in place, so people always know where they are and where else they can go.

Workspace stats and recent activity.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

export default function TabsDemo() {
  return (
    <Tabs defaultValue="overview" className="max-w-md">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="projects">Projects</TabsTrigger>
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="billing" disabled>
          Billing
        </TabsTrigger>
      </TabsList>
      <TabsContent value="overview">Workspace stats and recent activity.</TabsContent>
      <TabsContent value="projects">Milestones and deadlines for your projects.</TabsContent>
      <TabsContent value="account">Your profile and preferences.</TabsContent>
    </Tabs>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

import type { ComponentProps } from "react";
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import { cx } from "@/lib/cx";
import { focusRing } from "@/lib/styles";
import type { WithClass } from "@/lib/types";

export function Tabs({ className, ...props }: WithClass<ComponentProps<typeof BaseTabs.Root>>) {
  return <BaseTabs.Root className={cx("w-full", className)} {...props} />;
}

// The row of tabs, with a green line under the active one that slides to the next when you
// change tab.
export function TabsList({ className, children, ...props }: WithClass<ComponentProps<typeof BaseTabs.List>>) {
  return (
    <BaseTabs.List className={cx("relative flex gap-1 border-b border-border", className)} {...props}>
      {children}
      <BaseTabs.Indicator className="absolute -bottom-px left-0 h-0.5 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] rounded-full bg-accent transition-[translate,width] duration-150 ease-out motion-reduce:transition-none" />
    </BaseTabs.List>
  );
}

export function TabsTrigger({ className, ...props }: WithClass<ComponentProps<typeof BaseTabs.Tab>>) {
  return (
    <BaseTabs.Tab
      className={cx(
        "cursor-pointer rounded-sm px-3 py-2 text-base font-medium text-text-secondary transition-colors",
        focusRing,
        "not-data-[disabled]:hover:text-text data-[active]:text-text data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
        className,
      )}
      {...props}
    />
  );
}

export function TabsContent({ className, ...props }: WithClass<ComponentProps<typeof BaseTabs.Panel>>) {
  return <BaseTabs.Panel className={cx("rounded-sm px-3 py-4 text-base text-text", focusRing, className)} {...props} />;
}

Props

PropTypeDefaultWhat it does
defaultValuestring—The value of the tab that starts selected, when the tabs manage their own state.
valuestring—The selected tab, when you control it yourself.
onValueChangefunction—Called with the value of the newly selected tab.
valuestring—On each TabsTrigger and TabsContent. It ties a tab to its panel. Required.
disabledbooleanfalseOn a TabsTrigger. Makes that tab impossible to select.

Every other prop goes to the Base UI Tabs underneath.

The thinking

Tabs are for content that belongs together but is never read together. Each panel stands on its own, and switching is cheap.

  • Built on Base UI: Base UI links each tab to its panel, selects tabs with the keyboard, and reports the position of the active tab so the indicator can slide to it.
  • Four parts: Tabs holds the state, TabsList is the row, TabsTrigger is one tab and TabsContent is its panel.
  • Tabs or Accordion: Tabs show one panel in a fixed place. An Accordion stacks sections so people can open several and read down the page.

Interface design

  • Underline: The row sits on a hairline, and the selected tab has a 2px accent line over it. Accent green is part of the component, so it shows here too.
  • Selected: The selected tab uses the main text color and the others use the secondary one, so the state doesn't depend on the line alone.
  • Disabled: A disabled tab is dimmed to half and shows the not-allowed cursor.

Interaction

  • Select: Click a tab. The accent line slides to it in 150ms.
  • Keyboard: The row is one stop in the tab order. The left and right arrows move focus between tabs, and Home and End jump to the first and last. Enter or Space selects the focused tab. A disabled tab can still be focused, so it can be found, but it can't be selected.
  • Hover: An unselected tab brightens under the pointer.
  • Motion: With reduced motion turned on the line jumps instead of sliding.

Empathy

  • Roles: The row is announced as a tab list, each tab reports whether it is selected, and each panel is labeled by its tab.
  • Panels can be focused: After the tabs, Tab moves into the panel, which shows a focus ring, so keyboard users can reach content that has nothing focusable in it.
  • Keep labels short: One or two words fit on a phone. The row scrolls sideways if it ever runs out of room.