Skip to content

Tabs

ARIA tabs with manual activation and three appearances.

Navigationintermediatenavigationtabspanelskeyboard

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

'use client'

import { Badge } from '@/components/ui/badge'
import { Tabs } from '@/components/ui/tabs'
import { DemoRow, DemoStage } from './_kit'

const items = [
  {
    id: 'overview',
    label: 'Overview',
    content: (
      <p className="text-sm text-ink-muted">
        Manual activation: arrow keys move focus between tabs, Enter or Space activates. A keyboard
        user arrowing past three tabs never mounts three panels.
      </p>
    ),
  },
  {
    id: 'activity',
    label: 'Activity',
    badge: <Badge size="sm">12</Badge>,
    content: <p className="text-sm text-ink-muted">Twelve events in the last 24 hours.</p>,
  },
  {
    id: 'settings',
    label: 'Settings',
    content: (
      <p className="text-sm text-ink-muted">Panel contents are only rendered when selected.</p>
    ),
  },
  {
    id: 'archive',
    label: 'Archive',
    disabled: true,
    content: <p className="text-sm text-ink-muted">Unreachable.</p>,
  },
]

export default function TabsDemo() {
  return (
    <DemoStage>
      <DemoRow label="Underline" description="The documentation default.">
        <Tabs items={items} className="w-full" label="Underline example" />
      </DemoRow>

      <DemoRow label="Enclosed" description="Reads as a segmented control inside a panel.">
        <Tabs
          items={items.slice(0, 3)}
          appearance="enclosed"
          className="w-full"
          label="Enclosed example"
        />
      </DemoRow>

      <DemoRow label="Pill" description="Wraps gracefully when there are many short tabs.">
        <Tabs items={items.slice(0, 3)} appearance="pill" className="w-full" label="Pill example" />
      </DemoRow>
    </DemoStage>
  )
}

components/demos/tabs.tsx

'use client'

import { Badge } from '@/components/ui/badge'
import { Tabs } from '@/components/ui/tabs'
import { DemoRow, DemoStage } from './_kit'

const items = [
  {
    id: 'overview',
    label: 'Overview',
    content: (
      <p className="text-sm text-ink-muted">
        Manual activation: arrow keys move focus between tabs, Enter or Space activates. A keyboard
        user arrowing past three tabs never mounts three panels.
      </p>
    ),
  },
  {
    id: 'activity',
    label: 'Activity',
    badge: <Badge size="sm">12</Badge>,
    content: <p className="text-sm text-ink-muted">Twelve events in the last 24 hours.</p>,
  },
  {
    id: 'settings',
    label: 'Settings',
    content: (
      <p className="text-sm text-ink-muted">Panel contents are only rendered when selected.</p>
    ),
  },
  {
    id: 'archive',
    label: 'Archive',
    disabled: true,
    content: <p className="text-sm text-ink-muted">Unreachable.</p>,
  },
]

export default function TabsDemo() {
  return (
    <DemoStage>
      <DemoRow label="Underline" description="The documentation default.">
        <Tabs items={items} className="w-full" label="Underline example" />
      </DemoRow>

      <DemoRow label="Enclosed" description="Reads as a segmented control inside a panel.">
        <Tabs
          items={items.slice(0, 3)}
          appearance="enclosed"
          className="w-full"
          label="Enclosed example"
        />
      </DemoRow>

      <DemoRow label="Pill" description="Wraps gracefully when there are many short tabs.">
        <Tabs items={items.slice(0, 3)} appearance="pill" className="w-full" label="Pill example" />
      </DemoRow>
    </DemoStage>
  )
}

components/ui/tabs.tsx

'use client'

import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * Tabs
 *
 * Implements the ARIA tabs pattern with *manual* activation: arrow keys move
 * focus, Enter or Space activates. Automatic activation is the wrong default
 * for a documentation site, where a panel may be expensive and a keyboard user
 * arrowing past three tabs should not mount all three.
 *
 * Home/End jump to the ends, and the tab list scrolls rather than wrapping on
 * narrow screens so the active tab is always reachable.
 */
export interface TabItem {
  id: string
  label: ReactNode
  content: ReactNode
  disabled?: boolean
  /** Optional count or status rendered after the label. */
  badge?: ReactNode
}

export interface TabsProps {
  items: TabItem[]
  defaultTab?: string
  /** Controlled selection. */
  value?: string
  onValueChange?: (id: string) => void
  appearance?: 'underline' | 'enclosed' | 'pill'
  className?: string
  label?: string
}

export function Tabs({
  items,
  defaultTab,
  value: controlledValue,
  onValueChange,
  appearance = 'underline',
  className,
  label = 'Tabs',
}: TabsProps) {
  const uid = useId()
  const [uncontrolled, setUncontrolled] = useState(defaultTab ?? items[0]?.id ?? '')
  const active = controlledValue ?? uncontrolled
  const refs = useRef<Record<string, HTMLButtonElement | null>>({})

  const select = (id: string) => {
    if (controlledValue === undefined) setUncontrolled(id)
    onValueChange?.(id)
  }

  const enabled = items.filter((item) => !item.disabled)

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
    const index = enabled.findIndex((item) => item.id === id)
    if (index === -1) return
    let nextIndex: number | null = null

    if (event.key === 'ArrowRight') nextIndex = (index + 1) % enabled.length
    else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + enabled.length) % enabled.length
    else if (event.key === 'Home') nextIndex = 0
    else if (event.key === 'End') nextIndex = enabled.length - 1
    else if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault()
      select(id)
      return
    }

    if (nextIndex === null) return
    event.preventDefault()
    const nextId = enabled[nextIndex]?.id
    if (nextId) refs.current[nextId]?.focus()
  }

  const activeItem = items.find((item) => item.id === active) ?? items[0]

  const listClasses = {
    underline: 'flex gap-1 border-b border-line',
    enclosed: 'flex gap-1 rounded-md border border-line bg-surface-sunken p-1',
    pill: 'flex flex-wrap gap-1.5',
  }[appearance]

  return (
    <div className={cn('flex flex-col', className)}>
      <div
        className={cn(
          'hide-scrollbar overflow-x-auto',
          appearance === 'underline' && 'border-b border-line',
        )}
      >
        <div
          role="tablist"
          aria-label={label}
          className={cn(listClasses, appearance === 'underline' && 'border-b-0')}
        >
          {items.map((item) => {
            const selected = item.id === active
            return (
              <button
                key={item.id}
                ref={(el) => {
                  refs.current[item.id] = el
                }}
                type="button"
                role="tab"
                id={`${uid}-tab-${item.id}`}
                aria-selected={selected}
                aria-controls={`${uid}-panel-${item.id}`}
                tabIndex={selected ? 0 : -1}
                disabled={item.disabled}
                onClick={() => select(item.id)}
                onKeyDown={(event) => onKeyDown(event, item.id)}
                className={cn(
                  'inline-flex shrink-0 items-center gap-2 text-sm font-medium whitespace-nowrap transition-colors duration-150',
                  'disabled:cursor-not-allowed disabled:opacity-50',
                  appearance === 'underline' &&
                    cn(
                      '-mb-px border-b-2 px-3 py-2',
                      selected
                        ? 'border-accent text-ink-strong'
                        : 'border-transparent text-ink-muted hover:text-ink',
                    ),
                  appearance === 'enclosed' &&
                    cn(
                      'rounded-sm px-3 py-1.5',
                      selected
                        ? 'bg-surface text-ink-strong shadow-xs'
                        : 'text-ink-muted hover:text-ink',
                    ),
                  appearance === 'pill' &&
                    cn(
                      'rounded-full border px-3 py-1',
                      selected
                        ? 'border-accent bg-accent-soft text-accent-soft-ink'
                        : 'border-line text-ink-muted hover:border-line-strong hover:text-ink',
                    ),
                )}
              >
                {item.label}
                {item.badge}
              </button>
            )
          })}
        </div>
      </div>

      {activeItem ? (
        <div
          role="tabpanel"
          id={`${uid}-panel-${activeItem.id}`}
          aria-labelledby={`${uid}-tab-${activeItem.id}`}
          tabIndex={0}
          className="pt-4 focus-visible:outline-2 focus-visible:outline-offset-2"
        >
          {activeItem.content}
        </div>
      ) : null}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For switching between views of the same subject. Manual activation is the default: arrow keys move focus, Enter or Space activates — so arrowing past three tabs does not mount three panels.

  • Tabs are for peer content. If one view is primary and the others are detail, use a page instead.
  • Keep labels to one or two words; the list scrolls rather than wrapping.
  • Only the selected tab is in the tab order — the group is a single tab stop.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Underline
  • Enclosed
  • Pill
  • With badge
  • Disabled tab

Accessibility

Roles
`tablist`, `tab` and `tabpanel` with `aria-selected` and `aria-controls` wired in both directions.
Keyboard
Arrow keys move, Home and End jump, Enter or Space activates.
Panel focus
The panel is focusable so keyboard users land in the content after activating.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.