# Tabs

Tabs with a gliding indicator and panels that slide in the direction you travelled.

**Category:** Navigation  
**Exports:** `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/tabs.json
```
The file lands at `components/ui/tabs.tsx`; import from `@/components/ui/tabs`.

## API — Pill
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `string` | — | Controlled value. |
| `defaultValue` | `string` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: string) => void` | — |  |
| `variant` | `"pill" \| "underline"` | `"pill"` |  |
| `children` | `TabsList, TabsTrigger, TabsContent` | — |  |

## Example — Pill

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

export default function TabsPillDemo({ variant = "pill" }: { variant?: "pill" | "underline" }) {
  return (
    <Tabs defaultValue="overview" variant={variant} className="w-full max-w-md">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="activity">Activity</TabsTrigger>
        <TabsTrigger value="settings">Settings</TabsTrigger>
      </TabsList>
      {[
        ["overview", "Overview", "Revenue is up 12% on last week. Two invoices are waiting on approval."],
        ["activity", "Activity", "Maya merged “Ember accent tokens” and opened three new issues."],
        ["settings", "Settings", "Notifications go to the #design channel every weekday at 9 am."],
      ].map(([value, title, body]) => (
        <TabsContent key={value} value={value}>
          <div className="rounded-xl border bg-background p-5">
            <p className="font-medium">{title}</p>
            <p className="mt-1 text-sm leading-6 text-muted-foreground">{body}</p>
          </div>
        </TabsContent>
      ))}
    </Tabs>
  );
}
```

More examples: “Underline” — call get_component_examples.

## Source — components/ui/tabs.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

type TabsContextValue = {
  id: string;
  value: string;
  direction: number;
  variant: "pill" | "underline";
  select: (value: string) => void;
  register: (value: string) => void;
};

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

function useTabs() {
  const ctx = React.useContext(TabsContext);
  if (!ctx) throw new Error("Tabs components must be used inside <Tabs>");
  return ctx;
}

export type TabsProps = {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  variant?: "pill" | "underline";
  className?: string;
  children: React.ReactNode;
};

export function Tabs({ value: valueProp, defaultValue = "", onValueChange, variant = "pill", className, children }: TabsProps) {
  const id = React.useId();
  const [inner, setInner] = React.useState(defaultValue);
  const value = valueProp ?? inner;
  const order = React.useRef<string[]>([]);
  const [direction, setDirection] = React.useState(0);

  const register = React.useCallback((v: string) => {
    if (!order.current.includes(v)) order.current.push(v);
  }, []);

  const select = React.useCallback(
    (next: string) => {
      if (next === value) return;
      setDirection(order.current.indexOf(next) > order.current.indexOf(value) ? 1 : -1);
      if (valueProp === undefined) setInner(next);
      onValueChange?.(next);
    },
    [value, valueProp, onValueChange],
  );

  return (
    <TabsContext.Provider value={{ id, value, direction, variant, select, register }}>
      <div className={cn("flex flex-col gap-4", className)}>{children}</div>
    </TabsContext.Provider>
  );
}

export function TabsList({ className, children, ...props }: React.ComponentProps<"div">) {
  const { variant } = useTabs();
  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
    if (!keys.includes(e.key)) return;
    const tabs = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)'));
    const current = tabs.indexOf(document.activeElement as HTMLButtonElement);
    let next = current;
    if (e.key === "ArrowRight") next = (current + 1) % tabs.length;
    if (e.key === "ArrowLeft") next = (current - 1 + tabs.length) % tabs.length;
    if (e.key === "Home") next = 0;
    if (e.key === "End") next = tabs.length - 1;
    e.preventDefault();
    tabs[next]?.focus();
    tabs[next]?.click();
  };
  return (
    <div
      role="tablist"
      onKeyDown={onKeyDown}
      className={cn(
        "relative inline-flex w-fit items-center",
        variant === "pill" ? "gap-0.5 rounded-xl bg-muted p-1 ring-1 ring-inset ring-border" : "gap-5 border-b border-border",
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
}

export type TabsTriggerProps = Omit<React.ComponentProps<"button">, "value"> & { value: string };

export function TabsTrigger({ value, className, children, ...props }: TabsTriggerProps) {
  const ctx = useTabs();
  const reduce = useReducedMotion();
  const active = ctx.value === value;
  React.useLayoutEffect(() => ctx.register(value), [ctx, value]);

  return (
    <button
      type="button"
      role="tab"
      id={`${ctx.id}-tab-${value}`}
      aria-selected={active}
      aria-controls={`${ctx.id}-panel-${value}`}
      tabIndex={active ? 0 : -1}
      onClick={() => ctx.select(value)}
      className={cn(
        "relative inline-flex cursor-pointer select-none items-center justify-center gap-1.5 whitespace-nowrap text-sm font-medium outline-none transition-colors",
        "focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40 [&_svg]:size-4",
        ctx.variant === "pill" ? "h-8 rounded-lg px-3.5" : "h-10 rounded-sm px-0.5",
        active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
        className,
      )}
      {...props}
    >
      {active && (
        <motion.span
          layoutId={`${ctx.id}-indicator`}
          aria-hidden
          className={cn(
            "absolute",
            ctx.variant === "pill"
              ? "inset-0 rounded-lg bg-background shadow-[0_1px_2px_rgb(0_0_0/0.12),0_0_0_1px_var(--color-border)] dark:bg-accent"
              : "inset-x-0 -bottom-px h-0.5 rounded-full bg-foreground",
          )}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 38 }}
        />
      )}
      <span className="relative z-10 inline-flex items-center gap-1.5">{children}</span>
    </button>
  );
}

export type TabsContentProps = Omit<React.ComponentProps<"div">, "ref"> & { value: string };

export function TabsContent({ value, className, children, ...props }: TabsContentProps) {
  const ctx = useTabs();
  const reduce = useReducedMotion();
  const active = ctx.value === value;
  const offset = reduce ? 0 : 16 * ctx.direction;
  // Skip the entrance on first mount without AnimatePresence initial={false},
  // which would also freeze every looping animation inside the panel.
  const first = React.useRef(true);
  React.useEffect(() => {
    first.current = false;
  }, []);

  return (
    <AnimatePresence mode="popLayout" custom={offset}>
      {active && (
        <motion.div
          key={value}
          role="tabpanel"
          id={`${ctx.id}-panel-${value}`}
          aria-labelledby={`${ctx.id}-tab-${value}`}
          tabIndex={0}
          custom={offset}
          variants={{
            enter: (o: number) => ({ opacity: 0, x: o, filter: "blur(4px)" }),
            center: { opacity: 1, x: 0, filter: "blur(0px)" },
            exit: (o: number) => ({ opacity: 0, x: -o, filter: "blur(4px)" }),
          }}
          initial={first.current ? "center" : "enter"}
          animate="center"
          exit="exit"
          transition={{ type: "spring", stiffness: 380, damping: 34 }}
          className={cn("rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
          {...(props as object)}
        >
          {children}
        </motion.div>
      )}
    </AnimatePresence>
  );
}
```