TabsDesign system
ComponentSite and product

Tabs

View tabs with product and site variants and an animated selection indicator.

npx context-ds add tabs
On the site
In the product

Usage

Use it for

Two to six sibling views. size="sm" in dense cards, pill on the site, glide={false} when your triggers draw their own selected surface.

Avoid

  • Tabs for actions
  • Underlined tabs

Dependencies: use-tab-glide, @radix-ui/react-tabs, class-variance-authority

Example

examples/tabs.tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";export function Product() {  return (    <div className="flex w-full max-w-md flex-col gap-6">      <Tabs defaultValue="all">        <TabsList aria-label="Monitors">          <TabsTrigger value="all">All</TabsTrigger>          <TabsTrigger value="active">Active</TabsTrigger>          <TabsTrigger value="paused">Paused</TabsTrigger>        </TabsList>        <TabsContent value="all" className="text-body-sm text-fg-muted">Every monitor in this workspace.</TabsContent>        <TabsContent value="active" className="text-body-sm text-fg-muted">Monitors checking on schedule.</TabsContent>        <TabsContent value="paused" className="text-body-sm text-fg-muted">Monitors you stopped.</TabsContent>      </Tabs>      <Tabs defaultValue="json">        <TabsList variant="segmented" aria-label="Output">          <TabsTrigger value="json">JSON</TabsTrigger>          <TabsTrigger value="markdown">Markdown</TabsTrigger>          <TabsTrigger value="html">HTML</TabsTrigger>        </TabsList>      </Tabs>      <Tabs defaultValue="day">        <TabsList size="sm" aria-label="Period">          <TabsTrigger value="day">Day</TabsTrigger>          <TabsTrigger value="week">Week</TabsTrigger>          <TabsTrigger value="month">Month</TabsTrigger>        </TabsList>      </Tabs>    </div>  );}export function Site() {  return (    <Tabs defaultValue="typescript">      <TabsList variant="pill" aria-label="Language">        <TabsTrigger value="typescript">TypeScript</TabsTrigger>        <TabsTrigger value="python">Python</TabsTrigger>        <TabsTrigger value="curl">cURL</TabsTrigger>      </TabsList>    </Tabs>  );}

Source

components/ui/tabs.tsx
"use client";import * as React from "react";import * as TabsPrimitive from "@radix-ui/react-tabs";import { cva } from "class-variance-authority";import { useTabGlide } from "@/hooks/use-tab-glide";import { cn } from "@/lib/utils";// The dashboard's tabs: plain labels, with one soft gray pill (no border) that glides to the selected one on a spring.// `segmented` sits them on a gray track with a white pill, so the unpicked ones still read as choices. Only the text// answers a hover, so nothing fades over the pill gliding in beneath. The site's `pill` tabs are pill-shaped labels, a// white pill outlined in brand blue gliding to the selected one (src/components/sections/faq.tsx).type Variant = "default" | "segmented" | "pill";type Size = "sm" | "md";// The track's inner corners: the control radius less the track's 2px padding, so the pill sits concentric in it.const INNER_RADIUS = "rounded-[calc(var(--ds-radius-control)-0.125rem)]";const SEGMENTED_SURFACE = "bg-surface shadow-[0_1px_3px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.03)]";const PILL_SURFACE: Record<Variant, string> = {  default: "bg-wash-pill",  segmented: SEGMENTED_SURFACE,  pill: "rounded-pill border border-brand bg-surface",};// The same surface on the selected trigger, for the first paint before the pill lands (or for a list with no pill).const SELF_SURFACE: Record<Variant, string> = {  default: "data-[state=active]:bg-wash-pill",  segmented: "data-[state=active]:bg-surface data-[state=active]:shadow-[0_1px_3px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.03)]",  pill: "data-[state=active]:border-brand data-[state=active]:bg-surface",};const tabsTriggerVariants = cva(  "inline-flex items-center justify-center gap-1.5 whitespace-nowrap outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&_svg]:size-4 [&_svg]:shrink-0",  {    variants: {      variant: {        default: "text-fg-subtle transition-[color] duration-200 ease-out hover:text-fg data-[state=active]:text-fg",        segmented: "text-fg-muted transition-[color] duration-200 ease-out hover:text-fg data-[state=active]:text-fg",        pill: "rounded-pill border border-transparent px-5 py-2.5 text-body leading-snug text-fg-muted transition-[background-color,border-color,color] duration-200 ease-out hover:bg-blue-10 hover:text-fg data-[state=active]:text-fg-brand data-[state=active]:hover:bg-transparent",      },      size: { sm: "", md: "" },    },    compoundVariants: [      { variant: "default", size: "md", className: "h-9 rounded-control px-4 text-body-sm" },      { variant: "default", size: "sm", className: `h-7 px-3 text-caption ${INNER_RADIUS}` },      { variant: "segmented", size: "md", className: `h-8 px-3.5 text-body-sm ${INNER_RADIUS}` },      { variant: "segmented", size: "sm", className: `h-7 px-3 text-caption ${INNER_RADIUS}` },    ],    defaultVariants: { variant: "default", size: "md" },  },);const ActiveValue = React.createContext<string | undefined>(undefined);const ListState = React.createContext<{ variant: Variant; size: Size; isPillPlaced: boolean }>({ variant: "default", size: "md", isPillPlaced: false });// The active value is mirrored here so the list can move its pill in the same render Radix switches tabs, whether the// value is controlled or not.function Tabs({ className, value, defaultValue, onValueChange, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);  const active = value ?? uncontrolled;  return (    <ActiveValue.Provider value={active}>      <TabsPrimitive.Root        data-slot="tabs"        value={active}        onValueChange={(next) => {          setUncontrolled(next);          onValueChange?.(next);        }}        className={cn("flex flex-col gap-4", className)}        {...props}      />    </ActiveValue.Provider>  );}function TabsList({  className,  variant = "default",  size = "md",  glide = true,  pillClassName,  children,  ...props}: React.ComponentProps<typeof TabsPrimitive.List> & {  variant?: Variant;  size?: Size;  /** Off for lists whose triggers draw their own selected surface. */  glide?: boolean;  /** The gliding pill's surface, for a list styled its own way. */  pillClassName?: string;}) {  const active = React.useContext(ActiveValue);  const pillRef = React.useRef<HTMLSpanElement>(null);  const isPlaced = useTabGlide(pillRef, active ?? "");  const listState = React.useMemo(() => ({ variant, size, isPillPlaced: glide && isPlaced }), [variant, size, glide, isPlaced]);  const pillRadius = variant === "pill" ? "" : variant === "default" && size === "md" ? "rounded-control" : INNER_RADIUS;  return (    <TabsPrimitive.List      data-slot="tabs-list"      className={cn(        "relative isolate inline-flex w-fit items-center",        variant === "pill" ? "gap-2" : variant === "segmented" ? "gap-0.5 rounded-control bg-neutral-5 p-0.5" : size === "sm" ? "gap-0.5" : "gap-1",        className,      )}      {...props}    >      {glide && <span ref={pillRef} aria-hidden className={cn("pointer-events-none absolute top-0 left-0 -z-10", pillRadius, PILL_SURFACE[variant], pillClassName, !isPlaced && "invisible")} />}      <ListState.Provider value={listState}>{children}</ListState.Provider>    </TabsPrimitive.List>  );}function TabsTrigger({ className, value, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {  const { variant, size, isPillPlaced } = React.useContext(ListState);  // Until the pill lands (or with no pill), the selected tab draws the surface itself, so the first paint matches.  return <TabsPrimitive.Trigger data-slot="tabs-trigger" data-tab-id={value} value={value} className={cn(tabsTriggerVariants({ variant, size }), !isPillPlaced && SELF_SURFACE[variant], className)} {...props} />;}function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {  return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;}export { Tabs, TabsList, TabsTrigger, TabsContent, tabsTriggerVariants };

Reference: context-dashboard c0ddf7a src/components/ui/tabs.tsx; website src/components/sections/faq.tsx (site pill tabs).