# Tabs

> A row of pill options inside a hairline ring, where a soft pill slides behind the one you pick.

- Page: https://ui.devigner.cc/components/tabs
- Category: Interface
- Requirements: React 18 or 19, Tailwind CSS v4
- Install: `npx devignerui add tabs` (writes components/ui/tabs.tsx)
- Packages it needs: cn, motion, devignerui

## What the demo shows

The live demo is a group of three options, Playlist, History and Settings, in a hairline ring. Click one, or tab in and press an arrow key, and a soft pill slides behind it while its text brightens.

## When to use it

- Picking one of two to six short values that change what is already on screen, such as a size, a view (grid or list) or a chart range.
- Settings panels and toolbars, where every option should stay visible and one press should switch.

## When to reach for something else

- Switching between different panels of content with their own headings; that is a tab list with tab panels, which this does not render.
- More than about six options or long labels. The row gets wide and cannot wrap; use a select.
- Turning one setting on or off; use a switch.

## Usage

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

<Tabs label="Section" options={["Playlist", "History", "Settings"]} />
```

Custom composition:

```tsx
<Tabs label="Size" size="lg" fill options={["sm", "md", "lg"]} value={size} onValueChange={setSize} />
```

### Controlled view switch

Grid and list, icon only, driving a list's layout.

```tsx
import * as React from "react";
import { IconLayoutGrid, IconLayoutList } from "@devigner-ui/icons";
import { Tabs } from "@/components/ui/tabs";

export function ViewSwitch() {
  const [view, setView] = React.useState<"grid" | "list">("grid");
  return (
    <Tabs
      label="View"
      value={view}
      onValueChange={setView}
      options={[
        { value: "grid", label: null, icon: <IconLayoutGrid /> },
        { value: "list", label: null, icon: <IconLayoutList /> },
      ]}
    />
  );
}
```

### Full-width plan picker in a form

Submits the chosen plan with the form; one plan is not available.

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

export function PlanField() {
  return (
    <form action="/checkout">
      <Tabs
        label="Plan"
        name="plan"
        size="lg"
        fill
        defaultValue="pro"
        options={[
          "free",
          "pro",
          { value: "team", label: "team (soon)", disabled: true },
        ]}
      />
      <button type="submit">Continue</button>
    </form>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| options | `(string \| { value, label?, icon?, activeIcon?, disabled? })[]` | - | The choices, left to right. A string is its own value and label. An object can add an icon, an activeIcon shown in its place while selected (a filled drawing), a different label, or disabled; label: null shows the icon alone and uses value as its accessible name. |
| value | `string` | - | Controlled value: the selected option's value. Pass with onValueChange. |
| defaultValue | `string` | the first enabled option | Selected option on first render when uncontrolled. |
| onValueChange | `(value: string) => void` | - | Called with the new value when an option is picked by click or arrow key. |
| label | `string` | - | Accessible name of the group, such as "Size". Set it unless a visible label names the group through aria-labelledby. |
| size | `"sm" \| "md" \| "lg"` | "md" | Option height: 24, 28 or 32px, with 11, 12 or 13px text. |
| fill | `boolean` | false | Stretches the group to its container and splits it into equal options. |
| name | `string` | - | Form field name; the selected value submits with the form. |
| disabled | `boolean` | false | Disables every option. Single options are disabled through options. |
| classNames | `TabsClassNames` | - | Slot classes: option (each pill's label) and indicator (the sliding pill). |

## Keyboard

| Keys | Action |
| --- | --- |
| Tab | Moves focus into the group, onto the selected option, and out again. A 2px ring in the ring color is drawn around the focused option. |
| Arrow keys | Select and focus the previous or next option, wrapping at the ends and skipping disabled ones, as native radios do. |
| Space | Selects the focused option when none is selected yet. |

## Accessibility

- Built on native radio inputs sharing one name inside a radiogroup named by label, so screen readers announce the group, each option and its position.
- Each option is a label wrapping its visually hidden radio, so the whole pill is the click target.
- Icons are aria-hidden. An icon-only option (label: null) takes its value as its accessible name, so make that value readable.
- The selected option is marked by the pill and by brighter text, not by color alone.
- Honors prefers-reduced-motion and an ancestor <MotionConfig reducedMotion="always">, so an in-app motion switch works too. With reduced motion the pill jumps to the new option.

## Theming

- The ring is border-border, the sliding pill bg-accent, unselected text text-muted-foreground (text-foreground/80 on hover) and selected text text-foreground, so it follows light and dark on its own.
- Options use the mono font. Pass font-sans (or any font) in classNames.option to change it.
- classNames.indicator restyles the pill, such as a brand fill with a light classNames.option text color.
- The pill glides on the shared layout spring and the text color changes over 150ms with ease-out.

## Edge cases

- The pill is placed from the selected option's offset inside the group, so a scrolling container or an animating parent (a panel resizing, a sheet sliding in) never sends it gliding; only a new value does. A resize, such as a late font load, moves it without a glide.
- With no enabled option and no value, nothing is selected and no pill is drawn.
- A value that matches no option leaves the group with no pill.
- Option values are the React keys, so they must be unique.
- Without name, each group gets its own generated one, so two groups on a page never share arrow keys.

## Troubleshooting

**The pill does not move when I click.**
With value passed the group is controlled: update it in onValueChange. Otherwise check that option values are unique.

**Arrow keys move into another group.**
Two groups were given the same name. Leave name out unless the group sits in a form, and give each form field its own name.

**The component renders with no background or the wrong colors.**
Colors use the standard shadcn/ui tokens only (background, foreground, primary, secondary, muted, accent, border, input, ring, destructive), so a project set up with shadcn/ui needs nothing extra and the component follows its theme, dark mode included. Without shadcn/ui, define those CSS variables for :root and .dark and map them in your Tailwind v4 @theme, or run npx shadcn init.
