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.
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.
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.
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
| Prop | Type | |
|---|---|---|
| className | string | Added to the component’s own classes, so yours wins |
| style | CSSProperties | Merged 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.
| Prop | Type | Default | |
|---|---|---|---|
| items | TabItem[] | [] | |
| value | string | — | Controlled |
| defaultValue | string | first 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 |
| size | number | 36 | Tab height; padding and type follow it |
| gap | number | 4 | |
| radius | number | size × 0.28 | |
| thickness | number | 2 | The underline and its rule, on line |
| full | boolean | false | Spreads the tabs across the width |
| stiffness | number | 420 | The indicator’s spring |
| damping | number | 38 | |
| label | string | '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 | |
|---|---|
| Tab | Into the tab list, then straight to the panel — one stop, not one per tab |
| ← → | Walks the tabs, wrapping, skipping disabled ones |
| Home / End | First and last |
| Enter / Space | Selects, 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.