Skip to content
VEXA
Navigation & menus

Tabs

Client leaf

Switch between related panels.

Live preview

Rendered from the installed VEXA source — the same files you receive.

Desktop
A summary of your workspace and recent usage.

Installation

Adds the component source to your project. Registry dependencies are pulled in automatically.

npx shadcn@latest add https://vexa.valfiguer.com/r/tabs.json

Installs to components/vexa/tabs.tsx

npm dependencies

  • @radix-ui/react-tabs

Registry dependencies

  • @vexa/utils

Other VEXA items this component needs. Install them first — they are resolved automatically by the CLI.

Source

Copy this into its target path, or install it with the command above.

components/vexa/tabs.tsx
"use client";

import type * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";

import { cn } from "@/lib/utils";

/**
 * Tabs — switch between related panels.
 *
 * Client leaf. Radix implements the APG tabs pattern: roving `Tab`/`Shift+Tab`
 * focus onto the active trigger, Arrow keys to move between triggers (Home/End
 * to jump), and `tab`/`tabpanel` roles with `aria-selected`/`aria-controls`
 * wired up. Automatic activation is the default; set `activationMode="manual"`
 * for panels whose content is expensive to render.
 */
export function Tabs({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
  return (
    <TabsPrimitive.Root
      data-slot="tabs"
      className={cn("flex flex-col gap-3", className)}
      {...props}
    />
  );
}

export function TabsList({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
  return (
    <TabsPrimitive.List
      data-slot="tabs-list"
      className={cn(
        "inline-flex h-10 w-fit items-center justify-center gap-1 rounded-vexa-md bg-vexa-muted p-1 text-vexa-muted-foreground",
        className,
      )}
      {...props}
    />
  );
}

export function TabsTrigger({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
  return (
    <TabsPrimitive.Trigger
      data-slot="tabs-trigger"
      className={cn(
        "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-vexa-sm px-3 py-1.5 text-sm font-medium",
        "transition-[color,background-color,box-shadow] duration-[var(--vexa-duration-fast)] ease-[var(--vexa-ease-standard)]",
        "outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring focus-visible:ring-offset-2 focus-visible:ring-offset-vexa-background",
        "disabled:pointer-events-none disabled:opacity-50",
        "data-[state=active]:bg-vexa-surface-raised data-[state=active]:text-vexa-foreground data-[state=active]:shadow-vexa-sm",
        "[&_svg]:size-4 [&_svg]:shrink-0",
        className,
      )}
      {...props}
    />
  );
}

export function TabsContent({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
  return (
    <TabsPrimitive.Content
      data-slot="tabs-content"
      className={cn(
        "outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring focus-visible:ring-offset-2 focus-visible:ring-offset-vexa-background",
        className,
      )}
      {...props}
    />
  );
}
Registry JSONr/tabs.json
r/tabs.json
{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "type": "registry:ui",
  "title": "Tabs",
  "description": "Switch between related panels.",
  "dependencies": [
    "@radix-ui/react-tabs"
  ],
  "registryDependencies": [
    "@vexa/utils"
  ],
  "files": [
    {
      "path": "registry/default/ui/tabs.tsx",
      "type": "registry:ui",
      "target": "components/vexa/tabs.tsx",
      "content": "\"use client\";\n\nimport type * as React from \"react\";\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Tabs — switch between related panels.\n *\n * Client leaf. Radix implements the APG tabs pattern: roving `Tab`/`Shift+Tab`\n * focus onto the active trigger, Arrow keys to move between triggers (Home/End\n * to jump), and `tab`/`tabpanel` roles with `aria-selected`/`aria-controls`\n * wired up. Automatic activation is the default; set `activationMode=\"manual\"`\n * for panels whose content is expensive to render.\n */\nexport function Tabs({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Root>) {\n  return (\n    <TabsPrimitive.Root\n      data-slot=\"tabs\"\n      className={cn(\"flex flex-col gap-3\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function TabsList({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.List>) {\n  return (\n    <TabsPrimitive.List\n      data-slot=\"tabs-list\"\n      className={cn(\n        \"inline-flex h-10 w-fit items-center justify-center gap-1 rounded-vexa-md bg-vexa-muted p-1 text-vexa-muted-foreground\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function TabsTrigger({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {\n  return (\n    <TabsPrimitive.Trigger\n      data-slot=\"tabs-trigger\"\n      className={cn(\n        \"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-vexa-sm px-3 py-1.5 text-sm font-medium\",\n        \"transition-[color,background-color,box-shadow] duration-[var(--vexa-duration-fast)] ease-[var(--vexa-ease-standard)]\",\n        \"outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring focus-visible:ring-offset-2 focus-visible:ring-offset-vexa-background\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        \"data-[state=active]:bg-vexa-surface-raised data-[state=active]:text-vexa-foreground data-[state=active]:shadow-vexa-sm\",\n        \"[&_svg]:size-4 [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function TabsContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Content>) {\n  return (\n    <TabsPrimitive.Content\n      data-slot=\"tabs-content\"\n      className={cn(\n        \"outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring focus-visible:ring-offset-2 focus-visible:ring-offset-vexa-background\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n"
    }
  ],
  "meta": {
    "frameworks": [
      "react",
      "next"
    ],
    "rsc": "client",
    "exports": [
      "Tabs",
      "TabsList",
      "TabsTrigger",
      "TabsContent"
    ],
    "cssVars": [
      "--vexa-muted",
      "--vexa-muted-foreground",
      "--vexa-surface-raised",
      "--vexa-foreground",
      "--vexa-ring",
      "--vexa-background"
    ],
    "a11y": "APG tabs: roving Tab focus onto the active trigger, Arrow/Home/End to move between triggers, and tab/tabpanel roles with aria-selected/aria-controls.",
    "example": "<Tabs defaultValue=\"a\"><TabsList><TabsTrigger value=\"a\">Account</TabsTrigger></TabsList><TabsContent value=\"a\">...</TabsContent></Tabs>",
    "tests": "registry/tests/tabs.test.tsx"
  }
}

Details

Accessibility

APG tabs: roving Tab focus onto the active trigger, Arrow/Home/End to move between triggers, and tab/tabpanel roles with aria-selected/aria-controls.

Usage

Switch between peer views of the same context inside one page region.

Anatomy

Tabs
Root; owns the active value.
TabsList
The tablist container.
TabsTrigger
A single tab.
TabsContent
The panel for a tab, matched by value.

Accessibility

APG tabs: tablist / tab / tabpanel with `aria-selected` and `aria-controls`.

Keyboard

KeyAction
Arrow / Home / EndMove between triggers
TabMove focus into the active panel (roving tabindex)

Focus: Roving focus on the active trigger; visible ring.

  • One TabsContent per TabsTrigger, matched by `value`.

Guidelines

Do

  • Keep tab labels short.
  • Match each trigger and content by `value`.
  • Use tabs for peer views, not a wizard.

Don't

  • Don't use tabs for sequential steps.
  • Don't place a Tab stop between each trigger.
  • Don't hide critical content behind a non-obvious tab.
Edit this page

Was this page helpful?

Stored locally in this browser. No account or server needed.