For the complete documentation index, see 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.
1
Support

Tabs

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

Playground

Live preview
A clear view of your project, without leaving this page.
Panels: arrows move focus; Enter/Space selects in manual mode. Links: demo-owned navigation, not a real router. Reset restores configuration and selection; copied code starts at Overview.
Customize Tabs
Customize
tabs-demo.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

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

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/tabs.json

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies3 required packages
  • @base-ui/react@^1.8.0
  • cn@^0.4.0
  • motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn@^0.4.0 motion@^12.38.0

Usage

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

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

Your project at a glance.
tabs-demo.tsx
"use client";

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

export const TabsDemo = () => (
  <Tabs
    aria-label="Project sections"
    items={[
      {
        content: "Your project at a glance.",
        label: "Overview",
        value: "overview",
      },
      {
        content: "Recent updates and conversations.",
        label: "Activity",
        value: "activity",
      },
      {
        content: "Manage your project preferences.",
        label: "Settings",
        value: "settings",
      },
    ]}
  />
);

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.

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.

tabs-links-demo.tsx
"use client";

import Link from "next/link";
import { useState } from "react";

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

export const TabsLinksDemo = () => {
  const [active, setActive] = useState("overview");
  return (
    <Tabs
      aria-label="Project navigation"
      variant="pill"
      items={["overview", "activity", "settings"].map((section) => ({
        active: active === section,
        key: section,
        link: (
          <Link
            href={`#${section}`}
            onClick={(event) => {
              if (
                event.metaKey ||
                event.ctrlKey ||
                event.shiftKey ||
                event.altKey
              ) {
                return;
              }
              event.preventDefault();
              setActive(section);
            }}
          >
            {section[0].toUpperCase() + section.slice(1)}
          </Link>
        ),
      }))}
    />
  );
};

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:

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

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.

Selected: profile. Edit the name, switch tabs, then return.

tabs-controlled-demo.tsx
"use client";

import { useState } from "react";

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

export const TabsControlledDemo = () => {
  const [value, setValue] = useState<string | null>("profile");
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Tabs
        aria-label="Account settings"
        variant="segmented"
        value={value}
        onValueChange={setValue}
        keepMounted
        items={[
          {
            content: (
              <label className="flex flex-col gap-2">
                Display name
                <input
                  className="h-9 rounded-md border bg-background px-3"
                  defaultValue="Alex"
                />
              </label>
            ),
            label: "Profile",
            value: "profile",
          },
          {
            content: "Your profile draft is retained when you return.",
            label: "Security",
            value: "security",
          },
          {
            content: "Unavailable",
            disabled: true,
            label: "Billing",
            value: "billing",
          },
        ]}
      />
      <p className="text-xs text-muted-foreground">
        Selected: {value ?? "none"}. Edit the name, switch tabs, then return.
      </p>
    </div>
  );
};

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

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:

$ pnpm dlx 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

  • 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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
activationMode"manual" | "automatic"

Panel mode: arrows move focus; automatic also selects. Manual uses Enter/Space to activate.

"manual"
activeboolean

Link item: route-owned active state. Adds aria-current=page; clicks do not change this state. Mark at most one item active.

false
animatedboolean

Motion indicator and panel fade. Reduced motion always disables movement.

true
contentReactNode

Panel item: content rendered in its corresponding panel. Accepts any React node.

Required for panel items
contentClassNamestring

Panel mode: classes applied to each panel element.

Not set
defaultValuestring | null

Panel mode: initial selection. Omitted selects the first enabled item; null selects none.

First enabled item
disabledboolean (item; demo control)

Panel item only: prevents selection. Demo disables Activity. Disabled Base UI tabs remain keyboard-focusable. Not a root prop or link-item prop.

false
itemsreadonly TabsPanelItem[] | readonly TabsLinkItem[]

Required. One homogeneous collection: value/label/content panels or link/active navigation items. Do not mix both kinds.

Required
keepMountedboolean

Panel mode: preserve inactive panel DOM and local state. Hidden panels cannot be interacted with.

false
keystring

Link item: optional stable identity for dynamic collections; falls back to the link element key, then index.

Link key or index
labelReactNode (panel item); string (demo control)

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.

"Project sections" (demo)
linkReactElement

Link item: native anchor or router Link, including its label. Must forward DOM props and ref to one anchor. No href/to adapter required.

Required for link items
listClassNamestring

Class overrides for the tab-bar element.

Not set
mode"panels" | "links" (demo only)

Playground composition, not a public prop. Link preview intercepts clicks and updates demo-owned route state without leaving the page.

"panels" (demo)
onValueChange(value, details) => void

Panel mode: Base UI change callback, including fallback changes after item removal/disabling. User changes can be canceled using details.cancel().

Not set
orientation"horizontal" | "vertical"

Tab-bar direction. Panel mode also controls keyboard navigation and panel placement.

"horizontal"
valuestring | null

Panel item: required unique string identity. On Tabs: controlled active item value; null selects no panel.

Not set
variant"underline" | "pill" | "segmented"

Indicator and tab-bar appearance.

"underline"

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

"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

  • Base UI: Accessible panel selection and keyboard behavior. Vandor UI supplies the item-based API, navigation composition, styling, and Motion indicators.
  • Motion: Local-coordinate indicator transitions and panel fades.