Navigation

Tabs

Tabs, with an indicator that lands on a measured tab rather than a counted one. Labels are different lengths, so an index multiplied by a width lands beside the tab rather than on it.

Usage

import { Tabs } from '@carabine/ui/tabs';

Three services, all healthy.

<Tabs
  items={[
    { id: 'overview', label: 'Overview', panel: <Overview /> },
    { id: 'activity', label: 'Activity', badge: 12, panel: <Activity /> },
    { id: 'settings', label: 'Settings', panel: <Settings /> },
  ]}
  label="Project"
/>

The panel is part of the item. A tab with nowhere to point is a button that changes colour, and keeping the two together is what lets the component wire aria-controls without being told twice.

Measured, not counted

The indicator reads offsetLeft and offsetWidth off the tab it is travelling to. Not getBoundingClientRect: a rect includes the transform that is currently mid-flight, so measuring during an animation gives you a moving answer and the bar chases itself.

The first placement is a layout effect, so the bar is under the right tab in the frame the component first paints — an indicator that slides in from the left on mount is one that was measured too late.

Examples

The line variant

chip is a sliding surface behind the label; line is an underline over a rule. The same measurement drives both.

Three services, all healthy.

<Tabs items={items} variant="line" label="Project" />

Manual activation

automatic selects as the arrows arrive, which is right when a panel is cheap. manual moves focus without selecting until Enter or Space — right when a panel costs a request, because arrowing past four tabs should not fire four of them.

Three services, all healthy.

<Tabs items={items} activation="manual" full label="Project" />

Customization

<Tabs
  items={items}
  size={42}        // tab height; padding and type follow it
  gap={6}
  radius={12}      // the chip's corners, and the tabs'
  thickness={3}    // the underline and its rule, on line
  full             // spreads the tabs across the width
  stiffness={420}  // the indicator's spring
  damping={38}
  theme="light"
/>

Styling reference

Tailwind utilities; there is no stylesheet for this component. The chip is a tint — no hairline, no shadow — so the surface under it stays visible, which is what keeps a row of tabs reading as one control rather than as a button that moved.

There is no hover surface. The affordance is the cursor and the label brightening, in CSS: a second surface appearing under the pointer would be a second answer to the question the indicator already answers.

API reference

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
itemsTabItem[][]
valuestringControlled
defaultValuestringfirst enabled
onValueChange(value: string) => void
activation'automatic' | 'manual''automatic'Whether the arrows choose as they arrive
variant'chip' | 'line''chip'A sliding surface, or an underline
sizenumber36Tab height; padding and type follow it
gapnumber4
radiusnumbersize × 0.28
thicknessnumber2The underline and its rule, on line
fullbooleanfalseSpreads the tabs across the width
stiffnessnumber420The indicator’s spring
dampingnumber38
labelstring'Tabs'Accessible name of the tab list
theme'dark' | 'light''dark'

TabItem carries id, label, icon, badge, panel, href, external and disabled. Give it an href and the tab is a link — for tabs that are really navigation.

Keyboard

Key
TabInto the tab list, then straight to the panel — one stop, not one per tab
← →Walks the tabs, wrapping, skipping disabled ones
Home / EndFirst and last
Enter / SpaceSelects, on manual activation

A roving tabindex, unlike the accordion's: tabs are one control with several settings, so Tab should pass through them, not walk them.