VIII Wayfinding

Star on GitHubFeedback
VIII WayfindingRadixot-tabs

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 Tabs

Example

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.json

Usage

This example also uses Rows.

npx shadcn@latest add https://thedirectors.agency/ui/r/rows.json
tabs-example.tsx
"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 sets data-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 mp ems, 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; mp below 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 --t and --b, the chosen tab's insets. It sets orientation="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 dir on the root, so without a dir the 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 changes dir afterwards passes dir itself, or remounts the tabs.

Props

Props of Tabs
PropTypeDefaultWhat 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 / onValueChangeRadixRadix 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 countnumber | stringHow many things are behind the tab, raised beside its name.
TabsCountnumber | stringInside 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

Where its move comes from
ComponentReferenceMove
TabsPOINT / SPECTRAA giant, cropped count
Tabs, rubatoThe performer's rubatoThe 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

How it moves
ComponentArticulationWhat moves
TabsInchwormThe leading edge first; the count rolls
Tabs, rubatoRubatoThe chosen word widens and thickens as the others give way
Tabs, openInchwormThe break in the rule moves to the chosen word, leading edge first