# Tabs

> Tabbed panels with a sliding light indicator and roving focus.

Category: Data Display. Docs: https://get-neonkit.vercel.app/components/tabs

## Installation

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

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/tabs`.

npm dependencies: `motion`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

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

<Tabs defaultValue="account">
  <TabsList>
    <TabsTrigger value="account">Account</TabsTrigger>
    <TabsTrigger value="password">Password</TabsTrigger>
  </TabsList>
  <TabsContent value="account">…</TabsContent>
  <TabsContent value="password">…</TabsContent>
</Tabs>
```

## Examples

### Settings

The lit pill slides to the selected tab.

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

const field =
  "h-9 w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 text-sm text-white/90 outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-white/30 focus:border-[rgb(var(--neon)/0.6)] focus:shadow-[0_0_0_3px_rgb(var(--neon)/0.15)]";
const panel = "grid gap-5 rounded-xl border border-white/10 bg-white/[0.03] p-5";

const notifications = [
  { id: "deploy-failed", label: "Failed deployments", hint: "Email and in-app", checked: true },
  { id: "deploy-ready", label: "Preview ready", hint: "In-app only", checked: true },
  { id: "mentions", label: "Comments and mentions", hint: "Email and in-app", checked: true },
  { id: "usage", label: "Weekly usage report", hint: "Email every Monday", checked: false },
];

export default function TabsDemo() {
  return (
    <Tabs defaultValue="account" className="w-full max-w-md">
      <TabsList>
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="password">Password</TabsTrigger>
        <TabsTrigger value="notifications">Notifications</TabsTrigger>
      </TabsList>

      <TabsContent value="account" className={panel}>
        <div className="grid gap-1">
          <h3 className="text-sm font-medium text-white/90">Profile</h3>
          <p className="text-sm text-white/55">This is how your name appears on deployments and comments.</p>
        </div>
        <label className="grid gap-1.5 text-xs font-medium text-white/55">
          Display name
          <input className={field} defaultValue="Maya Okafor" />
        </label>
        <label className="grid gap-1.5 text-xs font-medium text-white/55">
          Email
          <input className={field} type="email" defaultValue="maya@lumenlabs.dev" />
        </label>
        <Button variant="solid" size="sm" className="justify-self-start">
          Save changes
        </Button>
      </TabsContent>

      <TabsContent value="password" className={panel}>
        <div className="grid gap-1">
          <h3 className="text-sm font-medium text-white/90">Password</h3>
          <p className="text-sm text-white/55">Last changed 3 months ago. You will stay signed in on this device.</p>
        </div>
        <label className="grid gap-1.5 text-xs font-medium text-white/55">
          Current password
          <input className={field} type="password" autoComplete="current-password" />
        </label>
        <label className="grid gap-1.5 text-xs font-medium text-white/55">
          New password
          <input className={field} type="password" autoComplete="new-password" placeholder="At least 12 characters" />
        </label>
        <Button variant="solid" size="sm" className="justify-self-start">
          Update password
        </Button>
      </TabsContent>

      <TabsContent value="notifications" className={panel}>
        <div className="grid gap-1">
          <h3 className="text-sm font-medium text-white/90">Notifications</h3>
          <p className="text-sm text-white/55">Choose what is worth interrupting you for.</p>
        </div>
        <div className="grid gap-3">
          {notifications.map((item) => (
            <label key={item.id} className="flex cursor-pointer items-start gap-3">
              <input
                type="checkbox"
                defaultChecked={item.checked}
                className="mt-0.5 size-4 shrink-0 cursor-pointer accent-[rgb(var(--neon))]"
              />
              <span className="grid gap-0.5">
                <span className="text-sm text-white/90">{item.label}</span>
                <span className="text-xs text-white/45">{item.hint}</span>
              </span>
            </label>
          ))}
        </div>
      </TabsContent>
    </Tabs>
  );
}
```

### Underline

variant="underline" draws a glowing line on a hairline track. Good for code.

```tsx
"use client";

import * as React from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

const url = "https://get-neonkit.vercel.app/r/tabs.json";

const commands = [
  { manager: "npm", command: `npx shadcn@latest add ${url}` },
  { manager: "pnpm", command: `pnpm dlx shadcn@latest add ${url}` },
  { manager: "yarn", command: `yarn dlx shadcn@latest add ${url}` },
  { manager: "bun", command: `bunx --bun shadcn@latest add ${url}` },
];

function CopyButton({ value }: { value: string }) {
  const [copied, setCopied] = React.useState(false);

  function copy() {
    navigator.clipboard.writeText(value).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    });
  }

  return (
    <Button
      variant="ghost"
      size="sm"
      onClick={copy}
      aria-label={copied ? "Copied" : "Copy command"}
      className="size-7 shrink-0 px-0 [&_svg]:size-3.5"
    >
      {copied ? <Check /> : <Copy />}
    </Button>
  );
}

export default function TabsInstall() {
  return (
    <Tabs variant="underline" defaultValue="npm" className="w-full max-w-xl gap-0 rounded-xl border border-white/10 bg-[#0a0b11]">
      <TabsList className="px-4">
        {commands.map(({ manager }) => (
          <TabsTrigger key={manager} value={manager} className="font-mono text-[13px]">
            {manager}
          </TabsTrigger>
        ))}
      </TabsList>
      {commands.map(({ manager, command }) => (
        <TabsContent key={manager} value={manager} className="flex items-center gap-3 rounded-b-xl py-3 pl-4 pr-2.5">
          <span aria-hidden className="select-none font-mono text-[13px] text-white/25">
            $
          </span>
          <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[13px] text-white/80">
            {command}
          </code>
          <CopyButton value={command} />
        </TabsContent>
      ))}
    </Tabs>
  );
}
```

### Colors

Every Tabs gets its own indicator, so several can sit side by side.

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

const charts = [
  {
    color: "violet",
    metric: "Visitors",
    values: { "24h": "1,284", "7d": "9,617", "30d": "41,902" },
  },
  {
    color: "lime",
    metric: "Deployments",
    values: { "24h": "38", "7d": "214", "30d": "906" },
  },
  {
    color: "pink",
    metric: "Errors",
    values: { "24h": "3", "7d": "27", "30d": "112" },
  },
] as const;

const ranges = ["24h", "7d", "30d"] as const;

export default function TabsColors() {
  return (
    <div className="flex flex-wrap justify-center gap-8">
      {charts.map((chart) => (
        <Tabs key={chart.color} color={chart.color} defaultValue="7d" className="items-center gap-5">
          <TabsList aria-label={`${chart.metric} time range`}>
            {ranges.map((range) => (
              <TabsTrigger key={range} value={range} className="tabular-nums">
                {range}
              </TabsTrigger>
            ))}
          </TabsList>
          {ranges.map((range) => (
            <TabsContent key={range} value={range} className="rounded-md text-center">
              <p className="text-2xl font-semibold tabular-nums tracking-tight text-white">{chart.values[range]}</p>
              <p className="mt-1 text-xs text-white/45">{chart.metric}</p>
            </TabsContent>
          ))}
        </Tabs>
      ))}
    </div>
  );
}
```

### Manual activation

Arrow keys move focus; Enter or Space selects. Disabled tabs are skipped.

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

const panels = [
  { value: "overview", label: "Overview", text: "3 projects, 12 members and 41 deployments this week." },
  { value: "deployments", label: "Deployments", text: "The last production deploy went live 2 minutes ago from main." },
  { value: "logs", label: "Logs", text: "Streaming runtime logs from 4 regions. Nothing above warning level today." },
];

export default function TabsManual() {
  return (
    <Tabs defaultValue="overview" activationMode="manual" variant="underline" className="w-full max-w-md">
      <TabsList>
        {panels.map((panel) => (
          <TabsTrigger key={panel.value} value={panel.value}>
            {panel.label}
          </TabsTrigger>
        ))}
        <TabsTrigger value="billing" disabled>
          Billing
        </TabsTrigger>
      </TabsList>
      {panels.map((panel) => (
        <TabsContent key={panel.value} value={panel.value} className="rounded-md text-sm leading-relaxed text-white/55">
          {panel.text}
        </TabsContent>
      ))}
    </Tabs>
  );
}
```

## API reference

### Tabs

Renders a div and accepts every native div attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `string` | — | Initially selected tab when uncontrolled. |
| `value` | `string` | — | Selected tab when controlled. |
| `onValueChange` | `(value: string) => void` | — | Called when the selection changes. |
| `variant` | `"pill" \| "underline"` | `"pill"` | Indicator style. |
| `activationMode` | `"automatic" \| "manual"` | `"automatic"` | Select on focus, or wait for Enter and Space. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### TabsTrigger

Renders a button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | — | Matches a TabsContent value. |
| `disabled` | `boolean` | `false` | Skipped by the keyboard and not clickable. |

### TabsContent

Renders a div that fades in when selected.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | — | Matches a TabsTrigger value. |
| `forceMount` | `boolean` | `false` | Keeps the panel mounted but hidden while inactive, so its state survives. |

## Accessibility

- Follows the WAI-ARIA tabs pattern: tablist, tab and tabpanel roles, with aria-selected and aria-controls wired up.
- Only the selected tab is in the Tab order. Left and Right arrows move between tabs, Home and End jump to the ends.
- Panels are focusable so keyboard users can reach content without interactive elements.
- The indicator jumps instead of sliding when the user prefers reduced motion.

## Source

`components/ui/tabs.tsx`

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const ease = [0.23, 1, 0.32, 1] as const;

const lists = {
  pill: "inline-flex w-fit items-center gap-1 rounded-xl border border-white/10 bg-white/[0.03] p-1",
  underline: "flex items-center gap-6 border-b border-white/10",
} as const;

const triggers = {
  pill: "h-8 rounded-lg px-3",
  underline: "h-10 rounded-md",
} as const;

const activeTriggers = {
  pill: "text-white",
  underline: "text-[rgb(var(--neon))]",
} as const;

const indicators = {
  pill: "absolute inset-0 border border-[rgb(var(--neon)/0.45)] bg-[rgb(var(--neon)/0.1)] shadow-[0_0_16px_rgb(var(--neon)/0.25),inset_0_0_12px_rgb(var(--neon)/0.08)]",
  underline:
    "absolute inset-x-0 -bottom-px h-0.5 bg-[rgb(var(--neon))] shadow-[0_0_10px_rgb(var(--neon)/0.6),0_0_2px_rgb(var(--neon)/0.9)]",
} as const;

type TabsVariant = keyof typeof lists;

type TabsContextValue = {
  baseId: string;
  value: string | undefined;
  select: (value: string) => void;
  variant: TabsVariant;
  activationMode: "automatic" | "manual";
  reduceMotion: boolean;
  /** False until the first change, so the initial panel doesn't fade in on load. */
  interacted: boolean;
};

const TabsContext = React.createContext<TabsContextValue | null>(null);

function useTabs(component: string) {
  const context = React.useContext(TabsContext);
  if (!context) throw new Error(`<${component}> must be used inside <Tabs>.`);
  return context;
}

const makeId = (baseId: string, part: "trigger" | "content", value: string) =>
  `${baseId}-${part}-${value.replace(/\s+/g, "-")}`;

export interface TabsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "defaultValue" | "dir"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  variant?: TabsVariant;
  /** "automatic" selects a tab as soon as it gets focus; "manual" waits for Enter or Space. */
  activationMode?: "automatic" | "manual";
}

const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(
  (
    {
      className,
      style,
      color,
      value: valueProp,
      defaultValue,
      onValueChange,
      variant = "pill",
      activationMode = "automatic",
      ...props
    },
    ref,
  ) => {
    const baseId = React.useId();
    const reduceMotion = useReducedMotion() ?? false;
    const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
    const [interacted, setInteracted] = React.useState(false);
    const value = valueProp !== undefined ? valueProp : uncontrolled;

    const select = React.useCallback(
      (next: string) => {
        if (next === value) return;
        setInteracted(true);
        if (valueProp === undefined) setUncontrolled(next);
        onValueChange?.(next);
      },
      [value, valueProp, onValueChange],
    );

    const context = React.useMemo(
      () => ({ baseId, value, select, variant, activationMode, reduceMotion, interacted }),
      [baseId, value, select, variant, activationMode, reduceMotion, interacted],
    );

    return (
      <TabsContext.Provider value={context}>
        <div
          ref={ref}
          style={neonVars(color, style)}
          className={cn("[--neon:var(--neon-accent,34_211_238)]", "flex flex-col gap-4", className)}
          {...props}
        />
      </TabsContext.Provider>
    );
  },
);
Tabs.displayName = "Tabs";

const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, onKeyDown, ...props }, ref) => {
    const { variant } = useTabs("TabsList");

    function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
      onKeyDown?.(event);
      if (event.defaultPrevented) return;

      const tabs = Array.from(
        event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)'),
      );
      const index = tabs.indexOf(event.target as HTMLButtonElement);
      if (index === -1) return;

      const last = tabs.length - 1;
      const next = (
        {
          ArrowRight: index === last ? 0 : index + 1,
          ArrowLeft: index === 0 ? last : index - 1,
          Home: 0,
          End: last,
        } as Record<string, number>
      )[event.key];
      if (next === undefined) return;

      event.preventDefault();
      tabs[next].focus();
    }

    return (
      <div
        ref={ref}
        role="tablist"
        aria-orientation="horizontal"
        onKeyDown={handleKeyDown}
        className={cn(lists[variant], className)}
        {...props}
      />
    );
  },
);
TabsList.displayName = "TabsList";

export interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  value: string;
}

const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
  ({ className, value, disabled, onClick, onFocus, children, ...props }, ref) => {
    const tabs = useTabs("TabsTrigger");
    const selected = tabs.value === value;

    return (
      <button
        ref={ref}
        type="button"
        role="tab"
        id={makeId(tabs.baseId, "trigger", value)}
        aria-selected={selected}
        aria-controls={makeId(tabs.baseId, "content", value)}
        // Roving focus: only the selected tab is in the Tab order. With nothing selected, all are.
        tabIndex={selected || tabs.value === undefined ? 0 : -1}
        disabled={disabled}
        data-state={selected ? "active" : "inactive"}
        onClick={(event) => {
          onClick?.(event);
          if (!event.defaultPrevented) tabs.select(value);
        }}
        onFocus={(event) => {
          onFocus?.(event);
          if (tabs.activationMode === "automatic" && !selected && !disabled) tabs.select(value);
        }}
        className={cn(
          "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap text-sm font-medium outline-none",
          "transition-colors duration-150 ease-out",
          "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
          "disabled:pointer-events-none disabled:opacity-40",
          triggers[tabs.variant],
          selected ? activeTriggers[tabs.variant] : "text-white/50 hover:text-white/80",
          className,
        )}
        {...props}
      >
        {selected && (
          <motion.span
            aria-hidden
            layoutId={tabs.reduceMotion ? undefined : `${tabs.baseId}-indicator`}
            initial={false}
            transition={{ duration: 0.3, ease }}
            // Set inline so motion can keep the corners round while the pill stretches.
            style={tabs.variant === "pill" ? { borderRadius: 8 } : undefined}
            className={indicators[tabs.variant]}
          />
        )}
        <span className="relative inline-flex items-center gap-2 [&_svg]:size-4 [&_svg]:shrink-0">
          {children}
        </span>
      </button>
    );
  },
);
TabsTrigger.displayName = "TabsTrigger";

export interface TabsContentProps
  extends Omit<
    React.HTMLAttributes<HTMLDivElement>,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" | "onAnimationIteration"
  > {
  value: string;
  /** Keep the panel in the DOM while inactive (hidden), e.g. to preserve form state. */
  forceMount?: boolean;
}

const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
  ({ className, value, forceMount = false, ...props }, ref) => {
    const tabs = useTabs("TabsContent");
    const selected = tabs.value === value;

    if (!selected && !forceMount) return null;

    return (
      <motion.div
        ref={ref}
        role="tabpanel"
        id={makeId(tabs.baseId, "content", value)}
        aria-labelledby={makeId(tabs.baseId, "trigger", value)}
        tabIndex={0}
        hidden={!selected}
        data-state={selected ? "active" : "inactive"}
        initial={tabs.interacted && !tabs.reduceMotion ? { opacity: 0 } : false}
        animate={{ opacity: selected ? 1 : 0 }}
        transition={tabs.reduceMotion ? { duration: 0 } : { duration: 0.15, ease }}
        className={cn(
          "outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
          className,
          // Tailwind 3's preflight lets a display class like `grid` override the hidden attribute.
          !selected && "hidden",
        )}
        {...props}
      />
    );
  },
);
TabsContent.displayName = "TabsContent";

export { Tabs, TabsList, TabsTrigger, TabsContent };
```
