FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Tabs

Tabs

pnpm dlx shadcn@latest add …/r/tabs.json

Accessible React tabs for Next.js, with an underline or pill that slides to the active tab and panels that glide in, built on Radix Tabs.

  • Shown on your profile and in emails.

    Underline
  • Shown on your profile and in emails.

    Pill
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

export function Settings() {
  return (
    <Tabs defaultValue="account">
      <TabsList aria-label="Settings">
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="password">Password</TabsTrigger>
      </TabsList>
      <TabsContent value="account">Change your name and email.</TabsContent>
      <TabsContent value="password">Change your password.</TabsContent>
    </Tabs>
  )
}
variants.tsx
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useId } from "react";

const variants = [
  { variant: "underline", label: "Underline" },
  { variant: "pill", label: "Pill" },
] as const;

/** One settings set: each panel is a different height, so switching shows it gliding to its size */
function Settings({ id, variant, label }: { id: string; variant: "underline" | "pill"; label: string }) {
  return (
    <Tabs defaultValue="account">
      <TabsList variant={variant} aria-label={`Settings, ${label.toLowerCase()} style`}>
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="password">Password</TabsTrigger>
        <TabsTrigger value="notifications">Notifications</TabsTrigger>
      </TabsList>
      <TabsContent value="account" className="grid gap-2">
        <label htmlFor={`${id}-name`} className="text-sm font-medium">
          Name
        </label>
        <Input id={`${id}-name`} defaultValue="Ada Lovelace" aria-describedby={`${id}-name-hint`} />
        <p id={`${id}-name-hint`} className="text-sm text-muted-foreground">
          Shown on your profile and in emails.
        </p>
      </TabsContent>
      <TabsContent value="password" className="grid gap-4">
        <div className="grid gap-2">
          <label htmlFor={`${id}-current`} className="text-sm font-medium">
            Current password
          </label>
          <Input id={`${id}-current`} type="password" autoComplete="current-password" />
        </div>
        <div className="grid gap-2">
          <label htmlFor={`${id}-new`} className="text-sm font-medium">
            New password
          </label>
          <Input id={`${id}-new`} type="password" autoComplete="new-password" />
        </div>
      </TabsContent>
      <TabsContent value="notifications" className="grid gap-4">
        <div className="flex items-center justify-between gap-4">
          <label htmlFor={`${id}-email`} className="text-sm font-medium">
            Email updates
          </label>
          <Switch id={`${id}-email`} defaultChecked />
        </div>
        <div className="flex items-center justify-between gap-4">
          <label htmlFor={`${id}-push`} className="text-sm font-medium">
            Push notifications
          </label>
          <Switch id={`${id}-push`} />
        </div>
      </TabsContent>
    </Tabs>
  );
}

/*
 * Both looks side by side, named underneath. Top-aligned, so switching one set never moves the other.
 */
export default function TabsVariants() {
  const id = useId();
  return (
    <ul className="flex w-full flex-wrap items-start justify-center gap-x-10 gap-y-8">
      {variants.map(({ variant, label }) => (
        <li key={variant} className="flex w-72 flex-col gap-4">
          <Settings id={`${id}-${variant}`} variant={variant} label={label} />
          <span className="text-center text-xs text-muted-foreground">{label}</span>
        </li>
      ))}
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/tabs.tsx
    "use client";
    
    import { blur, duration, ease, spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva, type VariantProps } from "class-variance-authority";
    import { type AnimationPlaybackControls, animate, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { Tabs as TabsPrimitive } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      type Ref,
      type RefObject,
      useContext,
      useId,
      useLayoutEffect,
      useRef,
      useState,
    } from "react";
    
    /*
     * Tabs, built on Radix Tabs (roadmap, 2026-10-16: "Panels switched by an underline that slides between tabs").
     * - The indicator slides to the active tab (layoutId on spring.snappy, .agents/rules/motion.md), scoped to each
     *   list, so two tab sets never trade it. Two looks: `underline` (the default), a 2px line riding the list's
     *   hairline, and `pill`, Switcher's filled pill on a muted track.
     * - The panel that appears comes through the blur and slides 12px in from the side of the tab you moved to
     *   (from the right when you move right), while its height glides from the old panel's to its own:
     *   duration.slow on ease.out, never a spring, clipped only while it moves. The filter and height are cleared
     *   afterwards. The old panel goes at once, so the two never overlap.
     * - Nothing animates on first paint. With reduced motion the indicator jumps and the panel only fades.
     * - A tablist of tabs, each controlling a tabpanel. Arrow keys move between tabs and show their panel; Home
     *   and End jump to the ends.
     */
    
    type Variant = "underline" | "pill";
    
    /** How far the new panel slides in, in px */
    const SLIDE = 12;
    /** The inline styles an entrance leaves on a panel, cleared when it's hidden again */
    const LEFTOVERS = ["opacity", "transform", "filter", "height", "overflow"];
    
    type TabsContextValue = {
      value: string;
      reduce: boolean;
      root: RefObject<HTMLDivElement | null>;
      /** The tab shown before the latest change. The root updates it after the panels' effects have read it. */
      previous: RefObject<string>;
      /** The shown panel's height, kept current, so the next panel glides from it */
      height: RefObject<number>;
    };
    
    const TabsContext = createContext<TabsContextValue | null>(null);
    const ListContext = createContext<{ variant: Variant }>({ variant: "underline" });
    
    /** Hands a node to our ref and to the caller's */
    function useMergedRef<T>(own: { current: T | null }, theirs?: Ref<T>) {
      return (node: T | null) => {
        own.current = node;
        if (typeof theirs === "function") theirs(node);
        else if (theirs) (theirs as { current: T | null }).current = node;
      };
    }
    
    /** A tab of this set (not of a set nested inside one of its panels) */
    function triggerOf(root: HTMLElement, value: string) {
      return Array.from(root.querySelectorAll<HTMLElement>("[data-slot=tabs-trigger]")).find(
        (trigger) => trigger.dataset.value === value && trigger.closest("[data-slot=tabs]") === root,
      );
    }
    
    /**
     * Panels of related content, one shown at a time, built on Radix Tabs. Holds the value (controlled or not)
     * so the indicator and the panels know which tab is shown.
     */
    function Tabs({
      value,
      defaultValue,
      onValueChange,
      className,
      ref,
      ...props
    }: ComponentProps<typeof TabsPrimitive.Root>) {
      const reduce = useReducedMotion() ?? false;
      const [internal, setInternal] = useState(defaultValue ?? "");
      const current = value ?? internal;
      const root = useRef<HTMLDivElement>(null);
      const setRef = useMergedRef(root, ref);
      const previous = useRef(current);
      const height = useRef(0);
    
      // Parents' layout effects run after their children's, so the panel that just appeared has already read the
      // tab it replaced
      useLayoutEffect(() => {
        previous.current = current;
      }, [current]);
    
      return (
        <TabsContext.Provider value={{ value: current, reduce, root, previous, height }}>
          <TabsPrimitive.Root
            ref={setRef}
            data-slot="tabs"
            value={current}
            onValueChange={(next) => {
              setInternal(next);
              onValueChange?.(next);
            }}
            className={cn("flex flex-col gap-4", className)}
            {...props}
          />
        </TabsContext.Provider>
      );
    }
    
    const tabsListVariants = cva("relative isolate flex items-center", {
      variants: {
        variant: {
          // The hairline is the track the underline rides on, and divides the tabs from the panel
          underline: "gap-4 border-b border-border",
          // The tabs sit 2px inside the track, so the track is a control height (36px)
          pill: "w-fit gap-0.5 rounded-lg bg-muted p-0.5",
        },
      },
      defaultVariants: { variant: "underline" },
    });
    
    const tabsTriggerVariants = cva(
      [
        "relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap",
        // The label's colour changes over the indicator's time, so the two arrive together
        "text-muted-foreground transition-colors duration-(--duration-base) ease-out hover:text-foreground data-[state=active]:text-foreground",
        "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
        "disabled:pointer-events-none disabled:opacity-(--opacity-disabled)",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)",
      ],
      {
        variants: {
          variant: {
            underline: "h-(--size-control-md) px-1",
            pill: "h-8 px-3",
          },
        },
        defaultVariants: { variant: "underline" },
      },
    );
    
    export type TabsListProps = ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>;
    
    /** The row of tabs. Give it a name with aria-label when the page has more than one set. */
    function TabsList({ className, variant, children, ...props }: TabsListProps) {
      const groupId = useId();
      return (
        <ListContext.Provider value={{ variant: variant ?? "underline" }}>
          <TabsPrimitive.List
            data-slot="tabs-list"
            data-variant={variant ?? "underline"}
            className={cn(tabsListVariants({ variant }), className)}
            {...props}
          >
            <LayoutGroup id={groupId}>{children}</LayoutGroup>
          </TabsPrimitive.List>
        </ListContext.Provider>
      );
    }
    
    function TabsTrigger({
      className,
      value,
      children,
      ref,
      ...props
    }: ComponentProps<typeof TabsPrimitive.Trigger>) {
      const tabs = useContext(TabsContext);
      const { variant } = useContext(ListContext);
      const node = useRef<HTMLButtonElement>(null);
      const setRef = useMergedRef(node, ref);
      const [radius, setRadius] = useState<number>();
    
      // A numeric radius lets motion keep the pill's corners true while it changes width between tabs. Until it's
      // read (server HTML), the class gives the same corners.
      useLayoutEffect(() => {
        if (variant === "pill" && node.current)
          setRadius(Number.parseFloat(getComputedStyle(node.current).borderTopLeftRadius) || undefined);
      }, [variant]);
    
      return (
        <TabsPrimitive.Trigger
          ref={setRef}
          data-slot="tabs-trigger"
          data-value={value}
          value={value}
          className={cn(tabsTriggerVariants({ variant }), className)}
          {...props}
        >
          {tabs?.value === value ? (
            // Tabs aren't stacking contexts: the indicator joins the list's layer (it's `isolate`), so the pill
            // passes behind every label as it slides
            <motion.span
              layoutId="tabs-indicator"
              aria-hidden
              data-slot="tabs-indicator"
              className={
                variant === "pill"
                  ? "pointer-events-none absolute inset-0 -z-10 rounded-md bg-background"
                  : // On the list's hairline: its lower pixel covers it
                    "pointer-events-none absolute inset-x-0 -bottom-px h-0.5 bg-foreground"
              }
              style={radius ? { borderRadius: radius } : undefined}
              transition={tabs.reduce ? { duration: 0 } : spring.snappy}
            />
          ) : null}
          {children}
        </TabsPrimitive.Trigger>
      );
    }
    
    export type TabsContentProps = ComponentProps<typeof TabsPrimitive.Content>;
    
    /**
     * One tab's panel. Hidden panels render nothing, unless `forceMount` keeps their content (and its state)
     * mounted while hidden.
     */
    function TabsContent({ className, value, forceMount, children, ref, ...props }: TabsContentProps) {
      const tabs = useContext(TabsContext);
      const node = useRef<HTMLDivElement>(null);
      const setRef = useMergedRef(node, ref);
      const active = tabs?.value === value;
    
      // The panel is shown or hidden in the same commit as the tab changes (Radix's Presence would show it one
      // render later), so its entrance can measure it and start before the first paint, never flashing in place
      // biome-ignore lint/correctness/useExhaustiveDependencies: only the panel being shown or hidden starts or stops its entrance
      useLayoutEffect(() => {
        const panel = node.current;
        if (!tabs || !active || !panel) return;
        const { root, previous, height, reduce } = tabs;
    
        // The shown panel's height, mid-glide too, so the next panel glides from wherever this one is
        const observer = new ResizeObserver(() => {
          height.current = panel.offsetHeight;
        });
        observer.observe(panel);
    
        const running: AnimationPlaybackControls[] = [];
        const from = previous.current;
        // On first paint the panel is simply there
        if (from !== value) {
          const box = root.current;
          const before = box ? triggerOf(box, from) : undefined;
          const after = box ? triggerOf(box, value) : undefined;
          // From the right when the new tab is to the right of the old one. Offsets, so right-to-left pages and
          // zoomed previews work too.
          const side = before && after && after.offsetLeft < before.offsetLeft ? -1 : 1;
          const start = height.current;
          const end = panel.offsetHeight;
    
          if (reduce) {
            running.push(animate(panel, { opacity: [0, 1] }, { duration: duration.base, ease: ease.out }));
          } else {
            const entrance = animate(
              panel,
              { opacity: [0, 1], x: [side * SLIDE, 0], filter: [blur.hidden, blur.shown] },
              { duration: duration.base, ease: ease.out },
            );
            // A filter left behind would make a stacking context and a containing block round the content
            entrance.then(() => panel.style.removeProperty("filter"));
            running.push(entrance);
    
            if (start > 0 && end > 0 && start !== end) {
              panel.style.overflow = "hidden";
              const glide = animate(panel, { height: [start, end] }, { duration: duration.slow, ease: ease.out });
              glide.then(() => {
                panel.style.removeProperty("height");
                panel.style.removeProperty("overflow");
              });
              running.push(glide);
            }
          }
        }
    
        // Hidden again, mid-entrance or not: stop, and leave nothing behind for the next time it's shown
        return () => {
          observer.disconnect();
          for (const animation of running) animation.stop();
          for (const property of LEFTOVERS) panel.style.removeProperty(property);
        };
      }, [active]);
    
      return (
        <TabsPrimitive.Content
          ref={setRef}
          data-slot="tabs-content"
          value={value}
          // We decide what's shown and mounted, so the panel changes in the same render as its tab
          forceMount
          hidden={!active}
          // No flex-1 (unlike shadcn): a flex-basis would override the height the glide sets. Add it to fill a
          // fixed-height Tabs.
          className={cn(
            "rounded-md outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
            className,
          )}
          {...props}
        >
          {active || forceMount ? children : null}
        </TabsPrimitive.Content>
      );
    }
    
    export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants, tabsTriggerVariants };
  5. 5

    Update the import paths to match your project.

Examples

Icons

Icons sit before the label at the icon size. Give icon-only tabs an aria-label.

Three deployments this week, all healthy.
icons.tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ActivityIcon, LayoutDashboardIcon, SettingsIcon } from "lucide-react";

export default function TabsIcons() {
  return (
    <Tabs defaultValue="overview" className="w-full max-w-sm">
      <TabsList variant="pill" aria-label="Project">
        <TabsTrigger value="overview">
          <LayoutDashboardIcon aria-hidden />
          Overview
        </TabsTrigger>
        <TabsTrigger value="activity">
          <ActivityIcon aria-hidden />
          Activity
        </TabsTrigger>
        <TabsTrigger value="settings">
          <SettingsIcon aria-hidden />
          Settings
        </TabsTrigger>
      </TabsList>
      <TabsContent value="overview" className="text-sm text-muted-foreground">
        Three deployments this week, all healthy.
      </TabsContent>
      <TabsContent value="activity" className="text-sm text-muted-foreground">
        Ada merged two pull requests and opened an issue about the sign-in page.
      </TabsContent>
      <TabsContent value="settings" className="text-sm text-muted-foreground">
        Name, domain and who can see this project.
      </TabsContent>
    </Tabs>
  );
}

Disabled

A disabled tab can't be picked, and the arrow keys skip it.

Your name and email, as others see them.
disabled.tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

export default function TabsDisabled() {
  return (
    <Tabs defaultValue="account" className="w-full max-w-xs">
      <TabsList aria-label="Settings">
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="billing" disabled>
          Billing
        </TabsTrigger>
        <TabsTrigger value="team">Team</TabsTrigger>
      </TabsList>
      <TabsContent value="account" className="text-sm text-muted-foreground">
        Your name and email, as others see them.
      </TabsContent>
      <TabsContent value="billing" className="text-sm text-muted-foreground">
        Your plan, payment method and invoices.
      </TabsContent>
      <TabsContent value="team" className="text-sm text-muted-foreground">
        Invite people and choose what they can change.
      </TabsContent>
    </Tabs>
  );
}

API reference

Tabs

The root, built on Radix Tabs. Also accepts every Radix Tabs Root prop. Arrow keys move between tabs and show their panel; Home and End jump to the ends.

PropTypeDefaultDescription
value?string–The tab shown, when you control it.
defaultValue?string–The tab shown at first.
onValueChange?(value: string) => void–Called with the new tab when the user picks one.
activationMode?"automatic" | "manual""automatic"Automatic shows a tab's panel as soon as the arrow keys reach it; manual waits for Enter or Space.
dir?"ltr" | "rtl"–The reading direction. The arrow keys follow it, and a panel still slides in from the side of its tab.

TabsList

The row of tabs, a tablist. Give it a name with aria-label when the page has more than one set.

PropTypeDefaultDescription
variant?"underline" | "pill""underline"Underline: a line slides along a hairline under the tabs. Pill: a filled pill slides on a muted track, like Switcher.
loop?booleantrueThe arrow keys wrap from the last tab to the first.

TabsTrigger

One tab.

PropTypeDefaultDescription
valuestring–The panel this tab shows.
disabled?booleanfalseSkips the tab.

TabsContent

One tab's panel, a tabpanel named by its tab. It comes in through a blur and slides 12px from the side you moved towards, while its height glides from the last panel's.

PropTypeDefaultDescription
valuestring–The tab that shows this panel.
forceMount?true–Keeps the panel's content mounted while it's hidden, so it keeps its state (a half-filled form) and stays in the HTML.

Related components

  • Switcher

    An accessible React segmented control for Next.js: one choice from a few options, with a pill that slides to the one you pick.

  • Yes. It follows the WAI-ARIA accordion pattern.

    Yes, server components included.
    Accordion

    An accessible React accordion for Next.js: sections that open and close with a smooth height change, as one card, separate cards or plain rows. Built on Radix.

  • Toggle Group

    An accessible React toggle group for Next.js: buttons you switch on and off independently, like Bold, Italic and Underline in a toolbar.