# Tabs

> Underline and pill tabs with a sliding indicator and roving-tabindex arrow-key navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/tabs
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/tabs.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/tabs-demo.tsx"
import { BarChart3, Settings, Users } from "lucide-react"

import { Tabs, type TabItem } from "@/components/tabs"

const items: TabItem[] = [
  {
    value: "overview",
    label: "Overview",
    icon: <BarChart3 className="size-4" aria-hidden />,
    content: "Revenue is up 12.4% this quarter, driven mostly by annual plan upgrades and lower churn in the SMB segment.",
  },
  {
    value: "team",
    label: "Team",
    icon: <Users className="size-4" aria-hidden />,
    content: "Eight teammates have access to this workspace. Invite more people from the members page.",
  },
  {
    value: "settings",
    label: "Settings",
    icon: <Settings className="size-4" aria-hidden />,
    content: "Manage billing details, notifications and API keys for this project.",
  },
  { value: "archive", label: "Archive", content: "Archived projects live here.", disabled: true },
]

export default function TabsDemo() {
  return (
    <div className="grid w-full max-w-lg grid-cols-[minmax(0,1fr)] gap-10">
      <section className="grid min-w-0 gap-3">
        <p className="text-sm font-medium text-muted-foreground">Underline</p>
        <Tabs items={items} variant="underline" defaultValue="overview" />
      </section>
      <section className="grid min-w-0 gap-3">
        <p className="text-sm font-medium text-muted-foreground">Pill</p>
        <Tabs items={items} variant="pill" defaultValue="team" />
      </section>
    </div>
  )
}
```

## Source

```tsx title="components/tabs.tsx"
"use client"

import * as React from "react"

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

export interface TabItem {
  value: string
  label: React.ReactNode
  content: React.ReactNode
  icon?: React.ReactNode
  disabled?: boolean
}

export interface TabsProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  items: TabItem[]
  variant?: "underline" | "pill"
  defaultValue?: string
  /** Controlled value */
  value?: string
  onValueChange?: (value: string) => void
}

interface IndicatorRect {
  left: number
  top: number
  width: number
  height: number
}

export function Tabs({
  items,
  variant = "underline",
  defaultValue,
  value,
  onValueChange,
  className,
  ...props
}: TabsProps) {
  const baseId = React.useId()
  const [inner, setInner] = React.useState(defaultValue ?? items.find((i) => !i.disabled)?.value ?? "")
  const [rect, setRect] = React.useState<IndicatorRect | null>(null)
  const current = value ?? inner

  // Measures a tab element; only called from ref callbacks / observers, never during render.
  const measure = React.useCallback((el: HTMLElement | null) => {
    if (!el) return
    const next = { left: el.offsetLeft, top: el.offsetTop, width: el.offsetWidth, height: el.offsetHeight }
    setRect((prev) =>
      prev && prev.left === next.left && prev.top === next.top && prev.width === next.width && prev.height === next.height
        ? prev
        : next
    )
  }, [])

  const listRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      if (!node) return
      const observer = new ResizeObserver(() => {
        measure(node.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]'))
      })
      observer.observe(node)
      return () => observer.disconnect()
    },
    [measure]
  )

  function select(next: string) {
    if (value === undefined) setInner(next)
    onValueChange?.(next)
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    const list = event.currentTarget.closest('[role="tablist"]')
    const tabs = Array.from(list?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)') ?? [])
    const index = tabs.indexOf(event.currentTarget)
    if (index < 0) return
    let target: number | null = null
    if (event.key === "ArrowRight") target = (index + 1) % tabs.length
    else if (event.key === "ArrowLeft") target = (index - 1 + tabs.length) % tabs.length
    else if (event.key === "Home") target = 0
    else if (event.key === "End") target = tabs.length - 1
    if (target === null) return
    event.preventDefault()
    tabs[target].focus()
    const next = tabs[target].dataset.value
    if (next) select(next)
  }

  return (
    <div className={cn("w-full min-w-0", className)} {...props}>
      <div
        ref={listRef}
        role="tablist"
        className={cn(
          "relative",
          "max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
          variant === "underline" ? "flex gap-1 border-b" : "inline-flex gap-1 rounded-xl bg-muted p-1"
        )}
      >
        {rect && (
          <span
            aria-hidden
            className={cn(
              "pointer-events-none absolute transition-[left,top,width,height] duration-300 ease-out motion-reduce:transition-none",
              variant === "underline" ? "rounded-full bg-primary" : "rounded-lg bg-background shadow-sm ring-1 ring-border"
            )}
            style={
              variant === "underline"
                ? { left: rect.left, width: rect.width, top: rect.top + rect.height - 2, height: 2 }
                : { left: rect.left, width: rect.width, top: rect.top, height: rect.height }
            }
          />
        )}
        {items.map((item) => {
          const active = item.value === current
          return (
            <button
              key={item.value}
              ref={active ? measure : undefined}
              type="button"
              role="tab"
              id={`${baseId}-tab-${item.value}`}
              data-value={item.value}
              aria-selected={active}
              aria-controls={`${baseId}-panel-${item.value}`}
              tabIndex={active ? 0 : -1}
              disabled={item.disabled}
              onClick={() => select(item.value)}
              onKeyDown={onKeyDown}
              className={cn(
                "relative z-10 inline-flex shrink-0 items-center gap-2 px-3.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50",
                variant === "underline" ? "h-10 rounded-t-md" : "h-8 rounded-lg",
                active ? "text-foreground" : "text-muted-foreground hover:text-foreground"
              )}
            >
              {item.icon}
              {item.label}
            </button>
          )
        })}
      </div>
      {items.map((item) => (
        <div
          key={item.value}
          role="tabpanel"
          id={`${baseId}-panel-${item.value}`}
          aria-labelledby={`${baseId}-tab-${item.value}`}
          hidden={item.value !== current}
          tabIndex={0}
          className="mt-4 rounded-lg text-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
        >
          {item.content}
        </div>
      ))}
    </div>
  )
}
```
