# Tabs

Declare panels once, or pass your router links directly. Three variants with smooth Motion indicators.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import { Tabs } from "@/components/ui/tabs";

const items = [
  {
    "content": "A clear view of your project, without leaving this page.",
    "label": "Overview",
    "value": "overview"
  },
  {
    "content": "Recent changes and conversations, all in one place.",
    "label": "Activity",
    "value": "activity"
  },
  {
    "content": "Manage project preferences and access.",
    "label": "Settings",
    "value": "settings"
  }
];

export function TabsDemo() {
  return (
    <Tabs aria-label={"Project sections"} variant="underline" orientation="horizontal" animated={true}
      activationMode="manual" keepMounted={false}
      items={items.map((item) => ({
        ...item,
        disabled: item.value === "activity" && false,
      }))}
    />
  );
}
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/tabs.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install @base-ui/react@^1.8.0 cn@^0.4.0 motion@^12.38.0
```

## Usage [#usage]

Declare each panel once. Labels, triggers, panels, and accessible relationships are generated internally.

```tsx
import { Tabs } from "@/components/ui/tabs";

export function ProjectTabs() {
  return (
    <Tabs
      aria-label="Project sections"
      items={[
        {
          value: "overview",
          label: "Overview",
          content: <p>Project overview.</p>,
        },
        {
          value: "activity",
          label: "Activity",
          content: <p>Recent activity.</p>,
        },
      ]}
    />
  );
}
```

Use your configured UI alias if it differs. Labels and content accept React nodes. Give panels unique, stable string values; do not put interactive controls inside a tab label. Empty collections render nothing, so asynchronously loaded navigation does not invent a tablist before its links arrive.

## Examples [#examples]

<ComponentPreview name="tabs-demo" title="tabs-demo.tsx">
  <TabsDemo />
</ComponentPreview>

### Variants and Orientation [#variants-and-orientation]

`underline` is the default, with a thin active rule. `pill` adds a rounded accent highlight without a track. `segmented` uses a muted track with a bordered active surface. All support `orientation="vertical"`; panel mode places content beside the list. Horizontal lists scroll inside their container instead of wrapping labels.

### Router Links, No Adapter [#router-links-no-adapter]

Use a homogeneous collection of `link` items instead of panels. Put the label inside the link once. Native anchors, Next.js `Link`, and TanStack Router `Link` keep their own destination and router-specific props. Tabs does not inspect `href` or `to`.

The runnable example intercepts clicks and updates demo-owned active state. It does not navigate to real pages.

<ComponentPreview name="tabs-links-demo" title="tabs-links-demo.tsx">
  <TabsLinksDemo />
</ComponentPreview>

For TanStack Start, derive `active` from the router. This illustrative fragment belongs in a client module; `Link` destinations must exist in your generated route tree:

```tsx
import { Link, useRouterState } from "@tanstack/react-router";
import { Tabs } from "@/components/ui/tabs";

export function ProjectNavigation() {
  const pathname = useRouterState({
    select: (state) => state.location.pathname,
  });
  return (
    <Tabs
      aria-label="Project navigation"
      items={[
        {
          link: <Link to="/overview">Overview</Link>,
          active: pathname === "/overview",
        },
        {
          link: <Link to="/activity">Activity</Link>,
          active: pathname === "/activity",
        },
      ]}
    />
  );
}
```

For Next.js, use `Link` from `next/link`, `href` on each element, and `usePathname()` for route matching. For plain anchors, pass `<a href="...">Label</a>`. No router package is installed by Tabs.

Mark at most one link item active. Active state is controlled exclusively by your application, so canceled navigation, loading routes, and browser Back/Forward do not get a separate optimistic selection. Match nested routes and query parameters according to your product. For dynamic/reordered link collections, provide a stable item `key` or a keyed link element.

Links must forward received `className`, ARIA attributes, event handlers, and ref to a single anchor. Existing handlers and refs are composed using Base UI `useRender`. Ordinary, modified, and middle clicks remain native link behavior. Tabs does not make links disabled; hide unavailable routes or implement that policy in your application. Do not mix link items and panel items; TypeScript rejects that collection.

### Controlled State and Drafts [#controlled-state-and-drafts]

Use `value` and `onValueChange` for controlled panels. Omit `defaultValue` to start at the first enabled panel; `null` explicitly selects none. In uncontrolled mode, Base UI falls back when the selected item is removed or disabled; callbacks receive the next value (possibly `null`) and event details. Call `details.cancel()` to reject a user-initiated change, not an automatic fallback.

<ComponentPreview name="tabs-controlled-demo" title="tabs-controlled-demo.tsx">
  <TabsControlledDemo />
</ComponentPreview>

Inactive panels unmount by default, resetting local state when revisited. `keepMounted` preserves all panel DOM and local state; inactive panels remain hidden and unavailable for interaction. Account for effects and data fetching in mounted content. Controlled selection must reference an enabled item; Tabs does not silently rewrite application-owned state.

### Consumer Setup and Optional Stories [#consumer-setup-and-optional-stories]

Requires React 19, Tailwind CSS 4 with shadcn theme tokens (`background`, `foreground`, `muted`, `muted-foreground`, `accent`, `border`, `ring`), `@base-ui/react@^1.8.0`, `cn@^0.4.0`, and `motion@^12.38.0`. The component includes its client boundary and no website-only imports or extra CSS. A Server Component can import it; callbacks and state must originate in a client module. React elements passed from a server still follow your framework's serialization rules.

The registry follows your configured UI alias. Review overwrite prompts if you already have a customized `tabs.tsx`; the Vandor item-based API differs from shadcn's compound Tabs API.

Install Tabs first, then optionally add its separate stories:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/tabs-stories.json
```

Use your existing matching `@storybook/react` packages (typed and composed with 10.6.1), discover `**/*.stories.tsx` in your UI directory, and load your global Tailwind/theme stylesheet. Stories use relative imports, need no addons, and do not reinstall Tabs or modify Storybook configuration. This project has no Storybook server; Storybook UI verification is not performed.

## Behavior & Accessibility [#behavior--accessibility]

* Panel mode uses Base UI tablist/tab/tabpanel semantics, unique IDs, visible focus, and label relationships. Name the list using `aria-label` or `aria-labelledby`.
* Manual activation is the default: arrows and Home/End move focus; Enter/Space selects. `activationMode="automatic"` selects on focus, best for instantly available content. Vertical lists use Up/Down; horizontal lists use Left/Right and preserve normal Up/Down page scrolling.
* Disabled panel items cannot be selected, but Base UI keeps them keyboard-focusable so they can be discovered.
* Link mode renders a labeled `nav` with actual links and `aria-current="page"`, without invented panel relationships or tab keyboard semantics.
* Each instance keeps one indicator canvas anchored to its own tab bar. Motion animates the active surface in local coordinates over 240ms with an ease-out curve, without bounce or a vertical detour when panel height changes. The canvas cannot enlarge the scrollable area; the bar scrolls horizontally only. Panels fade in over 160ms without delaying selection or retaining outgoing interactive panels. Initial active content is visible without a first-paint fade.
* `animated={false}` or a reduced-motion preference makes indicator movement and panel fades immediate.
* `className` styles the root, `listClassName` the bar, and `contentClassName` each panel. Component-specific item props are documented above; arbitrary trigger/panel props and children overrides are not public APIs.

## Props [#props]

| Prop             | Type                                                  | Default                   | Description                                                                                                                                                     |
| ---------------- | ----------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| activationMode   | "manual" \| "automatic"                               | "manual"                  | Panel mode: arrows move focus; automatic also selects. Manual uses Enter/Space to activate.                                                                     |
| active           | boolean                                               | false                     | Link item: route-owned active state. Adds aria-current=page; clicks do not change this state. Mark at most one item active.                                     |
| animated         | boolean                                               | true                      | Motion indicator and panel fade. Reduced motion always disables movement.                                                                                       |
| content          | ReactNode                                             | Required for panel items  | Panel item: content rendered in its corresponding panel. Accepts any React node.                                                                                |
| contentClassName | string                                                | Not set                   | Panel mode: classes applied to each panel element.                                                                                                              |
| defaultValue     | string \| null                                        | First enabled item        | Panel mode: initial selection. Omitted selects the first enabled item; null selects none.                                                                       |
| disabled         | boolean (item; demo control)                          | false                     | Panel item only: prevents selection. Demo disables Activity. Disabled Base UI tabs remain keyboard-focusable. Not a root prop or link-item prop.                |
| items            | readonly TabsPanelItem\[] \| readonly TabsLinkItem\[] | Required                  | Required. One homogeneous collection: value/label/content panels or link/active navigation items. Do not mix both kinds.                                        |
| keepMounted      | boolean                                               | false                     | Panel mode: preserve inactive panel DOM and local state. Hidden panels cannot be interacted with.                                                               |
| key              | string                                                | Link key or index         | Link item: optional stable identity for dynamic collections; falls back to the link element key, then index.                                                    |
| label            | ReactNode (panel item); string (demo control)         | "Project sections" (demo) | Panel item: required React node for its trigger. Playground control instead sets the root aria-label; it labels the tablist in panel mode and nav in link mode. |
| link             | ReactElement                                          | Required for link items   | Link item: native anchor or router Link, including its label. Must forward DOM props and ref to one anchor. No href/to adapter required.                        |
| listClassName    | string                                                | Not set                   | Class overrides for the tab-bar element.                                                                                                                        |
| mode             | "panels" \| "links" (demo only)                       | "panels" (demo)           | Playground composition, not a public prop. Link preview intercepts clicks and updates demo-owned route state without leaving the page.                          |
| onValueChange    | (value, details) => void                              | Not set                   | Panel mode: Base UI change callback, including fallback changes after item removal/disabling. User changes can be canceled using details.cancel().              |
| orientation      | "horizontal" \| "vertical"                            | "horizontal"              | Tab-bar direction. Panel mode also controls keyboard navigation and panel placement.                                                                            |
| value            | string \| null                                        | Not set                   | Panel item: required unique string identity. On Tabs: controlled active item value; null selects no panel.                                                      |
| variant          | "underline" \| "pill" \| "segmented"                  | "underline"               | Indicator and tab-bar appearance.                                                                                                                               |

Tabs forwards native div attributes/ref to the panel root, or nav attributes/ref to the navigation root, except children. aria-label/aria-labelledby labels the tablist in panel mode and the nav in link mode. Root state/layout, generated children and navigation semantics are managed internally; link elements must forward DOM props and refs to their anchor.

## Source [#source]

```tsx
"use client";

import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ComponentProps, ReactElement, ReactNode, RefObject } from "react";

type TabsVariant = "underline" | "pill" | "segmented";

interface TabsPanelItem<Value extends string = string> {
  value: Value;
  label: ReactNode;
  content: ReactNode;
  disabled?: boolean;
  link?: never;
  active?: never;
}

interface TabsLinkItem {
  link: ReactElement;
  active?: boolean;
  /** Optional stable identity for dynamic or reordered link collections. */
  key?: string;
  value?: never;
  label?: never;
  content?: never;
  disabled?: never;
}

type TabsCommonProps = Omit<
  ComponentProps<"div">,
  "children" | "defaultValue" | "ref"
> & {
  variant?: TabsVariant;
  orientation?: "horizontal" | "vertical";
  animated?: boolean;
  listClassName?: string;
};

type TabsPanelProps<Value extends string = string> = TabsCommonProps & {
  items: readonly TabsPanelItem<Value>[];
  value?: Value | null;
  defaultValue?: Value | null;
  onValueChange?: (
    value: Value | null,
    details: TabsPrimitive.Root.ChangeEventDetails
  ) => void;
  activationMode?: "manual" | "automatic";
  keepMounted?: boolean;
  contentClassName?: string;
  ref?: ComponentProps<"div">["ref"];
};

type TabsNavigationProps = TabsCommonProps & {
  items: readonly TabsLinkItem[];
  ref?: ComponentProps<"nav">["ref"];
  value?: never;
  defaultValue?: never;
  onValueChange?: never;
  activationMode?: never;
  keepMounted?: never;
  contentClassName?: never;
};

type TabsProps<Value extends string = string> =
  | TabsPanelProps<Value>
  | TabsNavigationProps;

const transition = { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };

const listClasses = (variant: TabsVariant, vertical: boolean) =>
  cn(
    "relative isolate flex w-max items-center",
    vertical ? "flex-col items-stretch" : "min-w-0",
    variant === "underline" &&
      (vertical
        ? "gap-1 border-s border-border"
        : "gap-1 border-b border-border"),
    variant === "pill" && "gap-1",
    variant === "segmented" && "gap-1 rounded-lg bg-muted p-1"
  );

const triggerClasses = (variant: TabsVariant) =>
  cn(
    "relative inline-flex min-h-9 shrink-0 items-center justify-center gap-2 px-3 py-2 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-active:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
    variant === "underline" ? "rounded-sm" : "rounded-md"
  );

const Indicator = ({
  variant,
  vertical,
  animated,
}: {
  variant: TabsVariant;
  vertical: boolean;
  animated: boolean;
}) => {
  const reduceMotion = useReducedMotion();
  const enabled = animated && !reduceMotion;
  // A single local canvas never projects between trigger parents. Moving a
  // centered preview or resizing its panel cannot add a viewport-space detour.
  const ref = useRef<SVGSVGElement>(null);
  const [box, setBox] = useState<{
    x: number;
    y: number;
    width: number;
    height: number;
  } | null>(null);
  useLayoutEffect(() => {
    const list = ref.current?.parentElement;
    if (!list) {
      return;
    }
    const win = list.ownerDocument.defaultView;
    if (!win) {
      return;
    }
    const measure = () => {
      const active = list.querySelector<HTMLElement>(
        '[data-slot="tabs-trigger"][aria-selected="true"], [data-slot="tabs-link"][aria-current="page"]'
      );
      if (!active) {
        setBox(null);
        return;
      }
      const bounds = list.getBoundingClientRect();
      const tab = active.getBoundingClientRect();
      const scaleX =
        list.offsetWidth && bounds.width ? bounds.width / list.offsetWidth : 1;
      const scaleY =
        list.offsetHeight && bounds.height
          ? bounds.height / list.offsetHeight
          : 1;
      let width = tab.width / scaleX;
      let height = tab.height / scaleY;
      let x =
        (tab.left - bounds.left) / scaleX - list.clientLeft + list.scrollLeft;
      let y = (tab.top - bounds.top) / scaleY - list.clientTop + list.scrollTop;
      const underline = variant === "underline";
      if (underline) {
        if (vertical) {
          x =
            win.getComputedStyle(list).direction === "rtl"
              ? (list.clientWidth || bounds.width / scaleX) - 2
              : 0;
          y += 8;
          width = 2;
          height = Math.max(0, height - 16);
        } else {
          y = (list.clientHeight || bounds.height / scaleY) - 2;
          height = 2;
        }
      }
      const next = {
        height,
        width,
        x,
        y,
      };
      setBox((previous) =>
        previous &&
        previous.x === next.x &&
        previous.y === next.y &&
        previous.width === next.width &&
        previous.height === next.height
          ? previous
          : next
      );
    };
    const resize = new win.ResizeObserver(measure);
    const observeSize = () => {
      resize.disconnect();
      resize.observe(list);
      for (const tab of list.querySelectorAll(
        '[data-slot="tabs-trigger"], [data-slot="tabs-link"]'
      )) {
        resize.observe(tab);
      }
      measure();
    };
    const mutations = new win.MutationObserver(observeSize);
    mutations.observe(list, {
      attributeFilter: ["aria-selected", "aria-current", "dir"],
      attributes: true,
      characterData: true,
      childList: true,
      subtree: true,
    });
    observeSize();
    win.addEventListener("resize", measure);
    return () => {
      mutations.disconnect();
      resize.disconnect();
      win.removeEventListener("resize", measure);
    };
  }, [variant, vertical]);
  return (
    <svg
      ref={ref}
      aria-hidden="true"
      focusable="false"
      data-slot="tabs-indicator"
      className="pointer-events-none absolute inset-0 h-full w-full overflow-hidden"
    >
      {box && box.width > 0 && box.height > 0 && (
        <motion.rect
          initial={false}
          animate={{
            attrX: box.x,
            attrY: box.y,
            height: box.height,
            width: box.width,
          }}
          transition={enabled ? transition : { duration: 0 }}
          rx={variant === "underline" ? 0 : 6}
          className={cn(
            variant === "underline" && "fill-foreground",
            variant === "pill" && "fill-accent",
            variant === "segmented" && "fill-background stroke-border"
          )}
          vectorEffect="non-scaling-stroke"
        />
      )}
    </svg>
  );
};

const TabButton = ({
  nativeProps,
}: {
  nativeProps: ComponentProps<"button">;
}) =>
  useRender({
    defaultTagName: "button",
    props: {
      ...nativeProps,
      children: (
        <span className="relative z-10 inline-flex items-center gap-2">
          {nativeProps.children}
        </span>
      ),
    },
    ref: nativeProps.ref,
  });

const NavigationItem = ({
  item,
  variant,
}: {
  item: TabsLinkItem;
  variant: TabsVariant;
}) => {
  const link = useRender({
    defaultTagName: "a",
    props: {
      "aria-current": item.active ? "page" : undefined,
      className: cn(triggerClasses(variant), "z-10 w-full"),
      "data-active": item.active ? "" : undefined,
      "data-slot": "tabs-link",
    },
    render: item.link,
  });
  return <div className="relative shrink-0">{link}</div>;
};

const PanelBody = ({
  children,
  hidden,
  animated,
  hydrated,
}: {
  children: ReactNode;
  hidden: boolean;
  animated: boolean;
  hydrated: RefObject<boolean>;
}) => {
  const reduceMotion = useReducedMotion();
  const enabled = animated && !reduceMotion;
  return (
    <motion.div
      initial={enabled && hydrated.current ? { opacity: 0 } : false}
      animate={{ opacity: hidden ? 0 : 1 }}
      transition={
        enabled ? { duration: 0.16, ease: transition.ease } : { duration: 0 }
      }
    >
      {children}
    </motion.div>
  );
};

const isNavigation = <Value extends string>(
  props: TabsProps<Value>
): props is TabsNavigationProps =>
  props.items.length > 0 && props.items[0].link !== undefined;

const Tabs = <Value extends string = string>(props: TabsProps<Value>) => {
  const id = useId();
  const hydrated = useRef(false);
  useEffect(() => {
    hydrated.current = true;
  }, []);

  if (props.items.length === 0) {
    return null;
  }

  if (isNavigation(props)) {
    const {
      items,
      variant = "underline",
      orientation = "horizontal",
      animated = true,
      className,
      listClassName,
      ...nativeProps
    } = props;
    return (
      <nav
        {...nativeProps}
        data-slot="tabs"
        data-variant={variant}
        data-orientation={orientation}
        className={cn("max-w-full", className)}
      >
        <div
          className="max-w-full overflow-x-auto overflow-y-hidden p-1"
          style={{ overflowY: "hidden" }}
        >
          <div
            data-slot="tabs-list"
            className={cn(
              listClasses(variant, orientation === "vertical"),
              listClassName
            )}
          >
            {items.map((item, index) => (
              <NavigationItem
                key={item.key ?? item.link.key ?? index}
                item={item}
                variant={variant}
              />
            ))}
            <Indicator
              variant={variant}
              vertical={orientation === "vertical"}
              animated={animated}
            />
          </div>
        </div>
      </nav>
    );
  }

  const {
    items,
    variant = "underline",
    orientation = "horizontal",
    animated = true,
    activationMode = "manual",
    keepMounted = false,
    className,
    listClassName,
    contentClassName,
    defaultValue,
    "aria-label": ariaLabel,
    "aria-labelledby": ariaLabelledBy,
    ...rootProps
  } = props as TabsPanelProps<Value>;
  return (
    <TabsPrimitive.Root
      {...rootProps}
      defaultValue={
        defaultValue === undefined
          ? (items.find((item) => !item.disabled)?.value ?? null)
          : defaultValue
      }
      orientation={orientation}
      data-slot="tabs"
      data-variant={variant}
      className={cn(
        "flex min-w-0 gap-4",
        orientation === "vertical" ? "flex-row" : "flex-col",
        className
      )}
    >
      <div
        className="max-w-full shrink-0 overflow-x-auto overflow-y-hidden p-1"
        style={{ overflowY: "hidden" }}
      >
        <TabsPrimitive.List
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          activateOnFocus={activationMode === "automatic"}
          data-slot="tabs-list"
          className={cn(
            listClasses(variant, orientation === "vertical"),
            listClassName
          )}
        >
          {items.map((item) => (
            <TabsPrimitive.Tab
              key={item.value}
              value={item.value}
              id={`${id}-tab-${encodeURIComponent(item.value)}`}
              disabled={item.disabled}
              data-slot="tabs-trigger"
              className={triggerClasses(variant)}
              render={(nativeProps, state) => (
                <TabButton
                  nativeProps={{
                    ...nativeProps,
                    "aria-controls":
                      state.active || keepMounted
                        ? `${id}-panel-${encodeURIComponent(item.value)}`
                        : undefined,
                  }}
                />
              )}
            >
              {item.label}
            </TabsPrimitive.Tab>
          ))}
          <Indicator
            variant={variant}
            vertical={orientation === "vertical"}
            animated={animated}
          />
        </TabsPrimitive.List>
      </div>
      {items.map((item) => (
        <TabsPrimitive.Panel
          key={item.value}
          value={item.value}
          id={`${id}-panel-${encodeURIComponent(item.value)}`}
          aria-labelledby={`${id}-tab-${encodeURIComponent(item.value)}`}
          keepMounted={keepMounted}
          data-slot="tabs-content"
          className={cn(
            "min-w-0 flex-1 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
            contentClassName
          )}
          render={(nativeProps, state) => (
            <div {...nativeProps}>
              <PanelBody
                hidden={state.hidden}
                animated={animated}
                hydrated={hydrated}
              >
                {nativeProps.children}
              </PanelBody>
            </div>
          )}
        >
          {item.content}
        </TabsPrimitive.Panel>
      ))}
    </TabsPrimitive.Root>
  );
};

export { Tabs };
export type {
  TabsLinkItem,
  TabsNavigationProps,
  TabsPanelItem,
  TabsPanelProps,
  TabsProps,
  TabsVariant,
};

```

## Credits [#credits]

* [Base UI](https://base-ui.com/react/components/tabs): Accessible panel selection and keyboard behavior. Vandor UI supplies the item-based API, navigation composition, styling, and Motion indicators.

* [Motion](https://motion.dev/docs/react-layout-animations): Local-coordinate indicator transitions and panel fades.
