Skip to content
Navigation & Steps primitive

Tabs

One tab stop for the whole list, arrow keys mapped through the reading direction, and a manual-activation mode so arrowing past five expensive panels does not fire four requests nobody asked for.

181 linesNo dependencies
  • tabs
  • tablist
  • panels
  • keyboard
  • vertical

What's included

  • components/ui/tabs.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add tabs

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Three deploys this week, none rolled back. The last one shipped at 09:42.

Source

One file. Create components/ui/tabs.tsx and paste.

components/ui/tabs.tsx
'use client'

/**
 * <Tabs> — one panel visible at a time, chosen from a row of labels.
 *
 * The keyboard contract is the whole component. A tab list is ONE tab stop:
 * Tab moves into it, lands on the selected tab, and the next Tab leaves —
 * straight to the panel, not through every other tab. Arrow keys move between
 * tabs, Home and End jump to the ends. That is the roving-tabindex pattern
 * below, and it is why the unselected tabs have `tabIndex={-1}`.
 *
 * TWO ACTIVATION MODES, BECAUSE THEY ARE DIFFERENT PROMISES
 *
 *  - `auto` (default): arrowing onto a tab selects it. Right when showing the
 *    panel is instant and free.
 *  - `manual`: arrowing only moves focus; Enter or Space selects. Right when
 *    a panel is expensive — a network request, a chart — because otherwise a
 *    keyboard user who arrows across five tabs to reach the last one fires
 *    four requests they never wanted.
 *
 * Arrow keys follow reading direction: in a right-to-left document the next
 * tab is to the LEFT, so ArrowLeft goes forward. Vertical tabs use Up/Down,
 * which have no direction to reverse.
 *
 * Every id is rooted in `useId()`. The panel is always in the DOM, hidden when
 * inactive, so `aria-controls` never points at nothing; only the selected
 * panel's CONTENT is mounted.
 */

import * as React from 'react'

export interface TabItem {
  id: string
  label: string
  content: React.ReactNode
  disabled?: boolean
  /** A count or short status shown after the label, e.g. "3". */
  badge?: string | number
}

export interface TabsProps {
  items: TabItem[]
  /** Controlled selection. Omit it to let the component keep its own. */
  value?: string
  defaultValue?: string
  onValueChange?: (id: string) => void
  activation?: 'auto' | 'manual'
  orientation?: 'horizontal' | 'vertical'
  /** Names the tab list for assistive tech — "Project sections". */
  label: string
  className?: string
}

export function Tabs({
  items,
  value,
  defaultValue,
  onValueChange,
  activation = 'auto',
  orientation = 'horizontal',
  label,
  className = '',
}: TabsProps) {
  const uid = React.useId()
  const firstEnabled = items.find((t) => !t.disabled)?.id ?? items[0]?.id
  const [inner, setInner] = React.useState(defaultValue ?? firstEnabled)
  const selectedId = value ?? inner
  // Focus and selection are separate in manual mode, so track focus itself.
  const [focusId, setFocusId] = React.useState(selectedId)
  const refs = React.useRef(new Map<string, HTMLButtonElement>())

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

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

  const focusTab = (id: string) => {
    setFocusId(id)
    refs.current.get(id)?.focus()
    if (activation === 'auto') select(id)
  }

  const onKeyDown = (e: React.KeyboardEvent) => {
    const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
    const horizontal = orientation === 'horizontal'
    const next = horizontal ? (rtl ? 'ArrowLeft' : 'ArrowRight') : 'ArrowDown'
    const prev = horizontal ? (rtl ? 'ArrowRight' : 'ArrowLeft') : 'ArrowUp'
    const at = enabled.findIndex((t) => t.id === focusId)

    let target: TabItem | undefined
    if (e.key === next) target = enabled[(at + 1) % enabled.length]
    else if (e.key === prev) target = enabled[(at - 1 + enabled.length) % enabled.length]
    else if (e.key === 'Home') target = enabled[0]
    else if (e.key === 'End') target = enabled[enabled.length - 1]
    if (!target) return
    e.preventDefault()
    focusTab(target.id)
  }

  const vertical = orientation === 'vertical'

  return (
    <div className={`${vertical ? 'flex gap-6' : ''} ${className}`}>
      <div
        role="tablist"
        aria-label={label}
        aria-orientation={orientation}
        onKeyDown={onKeyDown}
        className={
          vertical
            ? 'flex w-44 shrink-0 flex-col border-s border-border'
            : 'flex overflow-x-auto border-b border-border [scrollbar-width:none]'
        }
      >
        {items.map((tab) => {
          const selected = tab.id === selectedId
          return (
            <button
              key={tab.id}
              ref={(el) => {
                if (el) refs.current.set(tab.id, el)
                else refs.current.delete(tab.id)
              }}
              type="button"
              role="tab"
              id={`${uid}-tab-${tab.id}`}
              aria-selected={selected}
              aria-controls={`${uid}-panel-${tab.id}`}
              disabled={tab.disabled}
              tabIndex={tab.id === focusId || (!items.some((t) => t.id === focusId) && selected) ? 0 : -1}
              onClick={() => {
                setFocusId(tab.id)
                select(tab.id)
              }}
              className={[
                'inline-flex shrink-0 items-center gap-2 whitespace-nowrap px-4 py-2.5 text-sm font-medium',
                'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
                'disabled:cursor-not-allowed disabled:opacity-50',
                vertical ? '-ms-px border-s-2 text-start' : '-mb-px border-b-2',
                selected
                  ? 'border-primary text-foreground'
                  : 'border-transparent text-muted-foreground hover:text-foreground',
              ].join(' ')}
            >
              {tab.label}
              {tab.badge !== undefined ? (
                <span className="rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums text-muted-foreground">
                  {tab.badge}
                </span>
              ) : null}
            </button>
          )
        })}
      </div>

      <div className={vertical ? 'min-w-0 flex-1' : ''}>
        {items.map((tab) => {
          const selected = tab.id === selectedId
          return (
            <div
              key={tab.id}
              role="tabpanel"
              id={`${uid}-panel-${tab.id}`}
              aria-labelledby={`${uid}-tab-${tab.id}`}
              hidden={!selected}
              // A panel with nothing focusable in it still needs to be
              // reachable, or a keyboard user can never get to its text.
              tabIndex={0}
              className="py-4 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
            >
              {selected ? tab.content : null}
            </div>
          )
        })}
      </div>
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
itemsrequiredTabItem[]—
labelrequiredNames the tab list for assistive tech — "Project sections".string—
valueControlled selection. Omit it to let the component keep its own.string—
defaultValuestring—
onValueChange(id: string) => void—
activation'auto' | 'manual''auto'
orientation'horizontal' | 'vertical''horizontal'
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More navigation & steps

View all
Open the full page for this primitive

Progress Steps

An ordered list in a nav, not a row of divs — so the count and the position come free, and aria-current names the step you are on. Horizontal and vertical, because four labels do not fit on a phone.

Navigation & Steps145 lines1 dep
Open the full page for this primitive

Tree View

One tab stop for the whole tree, with the ARIA tree keyboard contract in full — including typeahead and the arrow keys mapped through the reading direction.

Navigation & Steps235 lines1 dep