Tabs
Words with their counts raised. A line inches to the one in view and the count of what's showing sinks into the rule; or the chosen word takes its width from the others; or the rule beside a stack of words opens onto the panel.
Report an issue with TabsExample
line
open
Identity, type and the websites that carry them, for teams who would rather say less and mean it.
Rest
Pointed at
rubato, rest
rubato, pointed at
open, rest
Three sites.
open, pointed at
Three sites.
Right to left
Disabled
Install
Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base along.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/tabs.jsonUsage
This example also uses Rows.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/ rows.json"use client"import * as React from "react"import { Row, RowKind, RowMeta, RowTitle, Rows } from "@/components/ui/rows"import { Tabs, TabsContent, TabsCount, TabsList, TabsTrigger } from "@/components/ui/tabs"const PROJECTS = [["Halden", "Identity", "2026"],["Northlight", "Web", "2026"],["Oda Studio", "Identity", "2025"],["Tidewater", "Motion", "2025"],["Marram", "Web", "2025"],["Quiet Hours", "Web", "2024"],["Felt & Field", "Identity", "2024"],]const KINDS = ["Identity", "Web", "Motion"]const count = (kind: string) => (kind === "all" ? PROJECTS.length : PROJECTS.filter((p) => p[1] === kind).length)const two = (n: number) => String(n).padStart(2, "0")function Work({ variant }: { variant: "line" | "rubato" }) {const [kind, setKind] = React.useState("all")return (<Tabs variant={variant} value={kind} onValueChange={setKind}><TabsList aria-label="Work"><TabsTrigger value="all" count={count("all")}>All</TabsTrigger>{KINDS.map((k) => (<TabsTrigger key={k} value={k} count={count(k)}>{k}</TabsTrigger>))}{variant === "line" ? <TabsCount>{two(count(kind))}</TabsCount> : null}</TabsList>{["all", ...KINDS].map((k) => (<TabsContent key={k} value={k}><Rows aria-label="Projects">{PROJECTS.filter((p) => k === "all" || p[1] === k).slice(0, variant === "line" ? 7 : 3).map(([name, kindName, year]) => (<Row key={name} href={`#${name}`}><RowTitle>{name}</RowTitle><RowKind>{kindName}</RowKind><RowMeta>{year}</RowMeta></Row>))}</Rows></TabsContent>))}</Tabs>)}const STUDIO = [["practice", "Practice", "Identity, type and the websites that carry them, for teams who would rather say less and mean it."],["people", "People", "Four designers and a developer, in one room, on one project at a time."],["method", "Method", "We write before we draw. The words decide the layout, and the layout decides the rest."],]export default function Example() {return (<div className="grid gap-y-10"><div className="grid gap-y-4"><span className="ot-label">line</span><Work variant="line" /></div><div className="grid gap-y-4"><span className="ot-label">rubato</span><Work variant="rubato" /></div><div className="grid gap-y-4"><span className="ot-label">open</span><Tabs variant="open" defaultValue="practice"><TabsList aria-label="The studio">{STUDIO.map(([v, name]) => <TabsTrigger key={v} value={v}>{name}</TabsTrigger>)}</TabsList>{STUDIO.map(([v, , text]) => <TabsContent key={v} value={v}><p className="ot-mp" style={{ margin: 0, maxWidth: "32ch" }}>{text}</p></TabsContent>)}</Tabs></div></div>)}Contract
- Underneath: Radix Tabs.
- Anatomy:
<div class="ot-tabs-root" data-variant="line | rubato | open">(Radix Root) holding<div class="ot-tabs" role="tablist">, then<button role="tab" aria-selected>for each tab, with an optional count in<sup>, then<span class="ot-tabs-line">; then the panels. - line (the default): the ink line follows the selected tab through
--x(left),--r(right inset) and--y, like an inchworm: script setsdata-dir="left | right", and the leading edge moves first while the trailing edge follows 110ms later. - Optional
.ot-tabs-count(aria-hidden) sets the number showing huge and thin, cropped by the rule it sinks into. It rolls when the number changes (up when it grows, down when it shrinks), and hides below 700px. - rubato: "robbed time", the performer's give and take inside a bar that keeps its length. No line: the chosen word is the mark. It widens and thickens (font-stretch 75% to 125%, weight 300 to 600, andante, breath) while the others condense and lighten to give it the room, and the words are spread to both ends of a bar held to 26
mpems, so the bar keeps its length while they give and take. Pointing leans a word a little wider (90%, 350) in pencil. Voices without a width axis change weight only. - open: the words stacked large (
mf, 300;mpbelow 40rem) like a headline beside the panel, with one hairline standing between the two columns ("Less is more."). The hairline breaks beside the chosen word, so the word opens onto its panel: the choice is marked by a missing line. The hairline overhangs the stack by space-4 at both ends, so the first or last word breaks it too. The break moves as the line does, leading edge first (data-dir="up | down", the trailing segment 110ms behind), from--tand--b, the chosen tab's insets. It setsorientation="vertical"unless you pass one. Forced colours draw the lines in CanvasText. - Keyboard: roving tabindex. Left and Right arrows (Up and Down in open) move and select, Home / End go to the ends. Radix writes
diron the root, so without adirthe tabs take the direction of the page around them as they mount: on a right-to-left page the words run from the right and Left and Right swap. It reads the page's direction once, at mount, as the menus do: a page that changesdirafterwards passesdiritself, or remounts the tabs.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
variant | "line" | "rubato" | "open" | "line" | On Tabs. line: a line inches to the word in view, leading edge first. rubato: no line; the chosen word widens and thickens (font-stretch 75% to 125%, weight 300 to 600) while the others give up the width, and the words are spread to both ends so the bar keeps its length. open: the words stacked large in ruled cells beside the panel, with a hairline between; the hairline breaks beside the chosen word so its cell opens onto the panel, and the break moves leading edge first. open sets orientation to vertical unless you pass one. |
value / defaultValue / onValueChange | Radix | Radix Tabs: roving focus, arrows (Left / Right, or Up / Down when vertical) and Home / End select. | |
dir | "ltr" | "rtl" | On Tabs. Left out, the tabs take the direction of the page around them as they mount, so on a right-to-left page the words run from the right and Left and Right swap. | |
TabsTrigger count | number | string | How many things are behind the tab, raised beside its name. | |
TabsCount | number | string | Inside TabsList. The count of what's showing, set huge and cropped by the rule; it rolls up when it grows and down when it shrinks. Hidden below 700px. |
Craft
Where its move comes from
| Component | Reference | Move |
|---|---|---|
| Tabs | POINT / SPECTRA | A giant, cropped count |
| Tabs, rubato | The performer's rubato | The chosen word takes its width from the others; the bar keeps its length |
| Tabs, open | "Less is more." | The rule beside the stacked words breaks at the chosen one |
How it moves
| Component | Articulation | What moves |
|---|---|---|
| Tabs | Inchworm | The leading edge first; the count rolls |
| Tabs, rubato | Rubato | The chosen word widens and thickens as the others give way |
| Tabs, open | Inchworm | The break in the rule moves to the chosen word, leading edge first |