Rows
An index of hairline-separated rows. The one you point at reverses out of ink and steps forward with an arrow; or repeats are set as ditto marks; or a ring before each row fills once you have been there.
Report an issue with RowsExample
Rest
Pointed at
Not a link
- HaldenIdentity
Ditto, pointed at
Trail, to come
Trail, pointed at
Trail, been
Trail, here
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/rows.jsonUsage
import { Row, RowKind, RowMeta, RowTitle, Rows, type RowsProps } from "@/components/ui/rows"const work = [["Halden", "Identity", "2026"],["Northlight", "Web", "2026"],["Oda Studio", "Identity", "2025"],["Tidewater", "Motion", "2025"],["Marram", "Web", "2025"],]// By year, then kind: the order a ledger keeps, so the repeats fall together.const byYear = [["Halden", "Identity", "2026"],["Kestrel", "Identity", "2026"],["Northlight", "Web", "2026"],["Oda Studio", "Web", "2025"],["Marram", "Web", "2025"],["Tidewater", "Motion", "2025"],]function Index({ variant, label, here, rows = work }: { variant?: RowsProps["variant"]; label: string; here?: string; rows?: string[][] }) {return (<Rows variant={variant} aria-label={label}>{rows.map(([title, kind, year]) => (<Row key={title} href={`#${title.toLowerCase().replace(" ", "-")}`} aria-current={title === here ? "page" : undefined}><RowTitle>{title}</RowTitle><RowKind>{kind}</RowKind><RowMeta>{year}</RowMeta></Row>))}</Rows>)}export default function Example() {return (<div className="grid w-full" style={{ gap: "var(--ot-space-7)" }}><Index label="Projects" /><div className="grid gap-3"><span className="ot-label">Ditto</span><Index variant="ditto" label="Projects by year" rows={byYear} /></div><div className="grid gap-3"><span className="ot-label">Trail: open one, and its ring fills</span><Index variant="trail" label="Projects you have seen" here="Oda Studio" /></div></div>)}const one = (props: { force?: string; current?: boolean }) => (<Row href="#halden" data-force={props.force} aria-current={props.current ? "page" : undefined}><RowTitle>Halden</RowTitle><RowKind>Identity</RowKind><RowMeta>2026</RowMeta></Row>)Contract
- Underneath: native list.
- Anatomy:
<ul class="ot-rows" data-variant="ditto | trail">with<li><a class="ot-rows-link">, holding.ot-rows-title,.ot-rows-kind,.ot-rows-meta, and the → drawn after them (empty alt text). - reverse (the default, no attribute). On hover or focus, the row reverses: an ink block rolls on and the text turns to paper. The title steps 8px forward and the arrow comes in. Script sets
data-edge="top | bottom"on pointer enter and leave, so the ink comes in from the side your hand entered and leaves toward the side it goes. Moving down the list, one block of ink seems to travel with you. - ditto: the ledger's and the typed list's ditto mark. A kind or a meta (a year) that repeats the row above is set as a ditto mark (″, pencil, centred over the word it stands for), so only what changes is written out and the marks make a rhythm down the columns, the barcode rhythm of SPECTRA. Rows works the repeats out after every render (
data-dittoon the cell) and the words stay in the page, so a screen reader hears every row in full. Pointing at or focusing a row spells its dittos back into words (allegro); no ink block, the title steps and the arrow comes in. Sort the rows so the repeats fall together. - trail: after "28 December", the index keeps count of where you've been. A ring hangs in the margin before each row (space-3, stepping in below 40rem): a hairline ring in rule-strong for a row not yet opened, filled with ink once the browser has visited the link (
:visited, which only allows colours, so the ring's paper fill is opaque and nothing is stored), the accent for the row witharia-current(where you are, the one accent). Pointing inks the ring's line and it grows a quarter with spiccato. No ink block. Forced colours draw it in CanvasText, VisitedText and Highlight. - Narrow (below 40rem) the title takes its own line and the kind and meta sit under it, a space-1 below, so a long title never runs into its kind.
- Keyboard: Tab through the links; focus shows what pointing shows.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
Rows variant | "reverse" | "ditto" | "trail" | "reverse" | reverse: the row you point at reverses out of ink. ditto: a kind or a year that repeats the row above is set as a ditto mark (″) in pencil, worked out after every render; pointing at or focusing a row spells it out. trail: a ring hangs before each row (stepping in below 40rem): a hairline ring for a row not yet opened, ink once the browser has visited the link (:visited, so nothing is stored), the accent for the row with aria-current. Pointing inks the ring's line. |
Row aria-current | "page" | … | Set on the link. trail marks it with the accent: where you are. | |
Row href | string | Makes the row a link. Without it, or asChild, the row is plain and doesn't react. | |
Row asChild | boolean | false | Render your own link (Next's Link, say) inside the row with the row's look. |
RowTitle | span | The name, set large. | |
RowKind | span | What it is, in graphite. | |
RowMeta | span | The quiet fact at the end: a year, a count. | |
Row onClick | MouseEventHandler<HTMLElement> | Passed to the inner link, slotted child, or plain div. Use href for navigation or asChild with a button for a keyboard-accessible action. |
Craft
Where its move comes from
| Component | Reference | Move |
|---|---|---|
| Index rows | "the uncreative" | The row reverses out of ink |
| Ditto rows | The ledger's ditto mark; SPECTRA's barcode rhythm | What repeats the row above is a ditto mark; pointing spells it out |
| Trail rows | "28 December" dots | A ring per row fills with ink once you've been there; where you are is the accent |
How it moves
| Component | Articulation | What moves |
|---|---|---|
| Index rows | Roller | The ink follows the pointer in and out |
| Ditto rows | Spell out | The ditto marks turn back into words, allegro |
| Trail rows | Spiccato | Pointing inks the ring and it grows a quarter, landing with one rebound |