Wuko
Components

Tabs

A roving-focus tab list with full keyboard navigation (arrow keys, Home, End) and an animated active indicator. Built on Radix Tabs.

Installation

terminal
npx shadcn@latest add @wuko/tabs

Usage

Tabs is a four-piece compound API: Tabs (Root, state container), TabsList (the row of triggers), TabsTrigger (one per tab, sibling under TabsList), and TabsContent(one per tab, sibling to TabsList). Each TabsTrigger's value matches one TabsContent's value.

Update your name, email, and profile photo.
components/settings-tabs.tsx
import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@/components/ui/tabs";

// Anatomy:
//   Tabs (Root, state container)
//   ├── TabsList
//   │   ├── TabsTrigger value="account"
//   │   ├── TabsTrigger value="password"
//   │   └── TabsTrigger value="api"
//   ├── TabsContent value="account"
//   ├── TabsContent value="password"
//   └── TabsContent value="api"
export function SettingsTabs() {
  return (
    <Tabs defaultValue="account">
      <TabsList>
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="password">Password</TabsTrigger>
        <TabsTrigger value="api">API keys</TabsTrigger>
      </TabsList>
      <TabsContent value="account">…</TabsContent>
      <TabsContent value="password">…</TabsContent>
      <TabsContent value="api">…</TabsContent>
    </Tabs>
  );
}

Controlled

For full control over the active tab (to sync with URL, server state, or external buttons), pass value and onValueChange. Below, the row of buttons under the tabs sets the active tab from outside the component.

First tab body.
External control:
components/controlled-tabs.tsx
"use client";

import { useState } from "react";

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

export function ControlledTabs() {
  const [tab, setTab] = useState("first");
  return (
    <Tabs value={tab} onValueChange={setTab}>
      <TabsList>
        <TabsTrigger value="first">First</TabsTrigger>
        <TabsTrigger value="second">Second</TabsTrigger>
        <TabsTrigger value="third">Third</TabsTrigger>
      </TabsList>
      <TabsContent value="first">First tab body.</TabsContent>
      <TabsContent value="second">Second tab body.</TabsContent>
      <TabsContent value="third">Third tab body.</TabsContent>
    </Tabs>
  );
}

As trigger (asChild)

Pass asChild to TabsTriggerto substitute your own element as the trigger. Radix applies the trigger's props (role, data-state, focus management) to your child. The most common use is URL-driven tabs. Render a Link as the trigger so navigation and tab state stay in sync.

The trigger is rendered as your own anchor element. Clicks update the tab AND navigate to the hash.
components/release-notes-tabs.tsx
import Link from "next/link";

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

// Pass asChild to substitute your own element as the trigger — Radix
// applies the trigger's props (role, data-state, ref) to your child.
// Common use: a Link that updates the URL when the tab changes.
export function ReleaseNotesTabs() {
  return (
    <Tabs defaultValue="overview">
      <TabsList>
        <TabsTrigger value="overview" asChild>
          <Link href="/notes#overview">Overview</Link>
        </TabsTrigger>
        <TabsTrigger value="changelog" asChild>
          <Link href="/notes#changelog">Changelog</Link>
        </TabsTrigger>
      </TabsList>
      <TabsContent value="overview">…</TabsContent>
      <TabsContent value="changelog">…</TabsContent>
    </Tabs>
  );
}

Props

Tabs (Root):

PropTypeDefaultDescription
valuestringControlled active tab value. Pair with onValueChange. Omit (and use defaultValue) for uncontrolled mode.
defaultValuestringInitial active tab value when uncontrolled. Ignored when value is provided.
onValueChange(value: string) => voidFires when the active tab changes. Required for controlled mode.
orientation"horizontal" | "vertical""horizontal"Layout direction. Vertical orientation rotates roving-focus arrow keys (Up/Down instead of Left/Right).
...restComponentPropsWithoutRef<typeof Radix.Tabs.Root>Forwarded to the underlying Radix Tabs.Root.

TabsTrigger:

PropTypeDefaultDescription
valuestringRequired. The unique value matching one TabsContent's value prop.
asChildbooleanfalseWhen true, renders the consumer's child element as the trigger (Radix Slot pattern). Useful for URL-driven tabs (Next Link, anchor) or custom button components.
disabledbooleanfalseDisables the trigger; it is skipped by roving focus.
...restComponentPropsWithoutRef<typeof Radix.Tabs.Trigger>Forwarded to the underlying Radix Tabs.Trigger button.

TabsList and TabsContentare thin wrappers around their Radix counterparts and accept all of those primitives' props.

Accessibility

  • Roving focus: arrow keys (Left/Right for horizontal, Up/Down for vertical), Home (first tab), End (last tab). Tabs not currently focused are skipped by Tab key. Only the active tab is in the tab order, matching the ARIA tabs spec.
  • The active TabsContent is reachable via Tab from the active trigger, and gets a focus-visible:outline when focused so long-content panels are keyboard-accessible.
  • asChilduses Radix's Slot pattern: the consumer's child element receives the trigger's ARIA role (role="tab"), aria-selected, aria-controls, and ref. The substituted element must accept those attributes. Anchors, buttons, and any forwardRef component work.
  • The active indicator bar is decorative: a CSS ::after pseudo-element driven by data-state="active". Not announced by screen readers; the role + aria-selected does the announcing.