Thread
A message scroller that keeps to the latest while you are at the end. Scrolled back, it stays put and counts what is new in an ink pill.
Report an issue with ThreadExample
Yesterday
First round of marks is up. Three directions, one arc.
The second one. The arc feels like a harbour.
That was the idea. I’ll take it further tomorrow.
Today
The Halden marks are ready for review.
Looking now. Can we see it at stamp size too?
Scroll up, then ask Ada for an update: the thread stays where you are and counts what arrived.
The proofs are back from the printer.
The blue ran on page four.
Can you send a photo?
It's in the drive.
Seen it. We reprint four.
I'll call them now.
Monday
Brief is signed. We start Wednesday.
Good. Keep the harbour in mind.
Yesterday
First round of marks is up. Three directions, one arc.
The second one. The arc feels like a harbour.
Today
The Halden marks are ready for review.
Looking now. Can we see it at stamp size too?
The anchor fills in at stamp size. The flag holds.
The proofs are back from the printer.
The blue ran on page four.
Can you send a photo?
It's in the drive.
Seen it. We reprint four.
Yesterday
First round of marks is up. Three directions, one arc.
The second one. The arc feels like a harbour.
Today
The Halden marks are ready for review.
Looking now. Can we see it at stamp size too?
The anchor fills in at stamp size. The flag holds.
Install
Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base, marker, scroll-area, and scrollbar along.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/thread.jsonUsage
This example also uses Button and Message.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/ button.json https:// thedirectors.agency/ ui/ r/ message.json"use client"import * as React from "react"import { Button } from "@/components/ui/button"import { Message, MessageAvatar, MessageBody, MessageBubble, MessageFooter, MessageHeader, MessageStatus } from "@/components/ui/message"import { Thread, ThreadDay } from "@/components/ui/thread"const updates = ["The stamp size holds. The anchor is gone, the flag stays.","I redrew the timetable grid. It reads better from a distance.","Sending the second round to the ferry office this afternoon.","They asked for the flag in one colour. I have it in ink.",]/** [who, "YYYY-MM-DDTHH:MM", words]; a string on its own is a day divider. */type Line = [who: "Ada" | "You", at: string, words: string] | stringfunction Lines({ lines }: { lines: Line[] }) {return lines.map((l, i) => {if (typeof l === "string") return <ThreadDay key={i}>{l}</ThreadDay>const [who, at, words] = lconst you = who === "You"return (<Message key={i} from={you ? "you" : "them"}><MessageAvatar alt={you ? "You" : "Ada Lindqvist"} /><MessageHeader name={you ? "You" : "Ada Lindqvist"} time={at.slice(11)} dateTime={at} /><MessageBody><MessageBubble align={you ? "end" : "start"}>{words}</MessageBubble></MessageBody></Message>)})}const proofs: Line[] = [["Ada", "2026-09-30T08:02", "The proofs are back from the printer."],["Ada", "2026-09-30T08:03", "The blue ran on page four."],["You", "2026-09-30T08:09", "Can you send a photo?"],["Ada", "2026-09-30T08:31", "It's in the drive."],["You", "2026-09-30T11:40", "Seen it. We reprint four."],["Ada", "2026-09-30T11:41", "I'll call them now."],]const week: Line[] = ["Monday",["Ada", "2026-09-28T15:10", "Brief is signed. We start Wednesday."],["You", "2026-09-28T15:12", "Good. Keep the harbour in mind."],"Yesterday",["Ada", "2026-09-29T16:02", "First round of marks is up. Three directions, one arc."],["You", "2026-09-29T16:20", "The second one. The arc feels like a harbour."],"Today",["Ada", "2026-09-30T09:40", "The Halden marks are ready for review."],["You", "2026-09-30T09:44", "Looking now. Can we see it at stamp size too?"],["Ada", "2026-09-30T09:51", "The anchor fills in at stamp size. The flag holds."],]export default function Example() {const [arrived, setArrived] = React.useState(0)const time = (n: number) => `09:${String(46 + n).padStart(2, "0")}`return (<div className="grid gap-(--ot-space-8)"><div className="grid max-w-xl justify-items-start gap-(--ot-space-5)"><Thread label="Halden thread" className="w-full"><ThreadDay>Yesterday</ThreadDay><Message><MessageAvatar alt="Ada Lindqvist" /><MessageHeader name="Ada Lindqvist" time="16:02" /><MessageBody><MessageBubble>First round of marks is up. Three directions, one arc.</MessageBubble></MessageBody></Message><Message from="you"><MessageAvatar alt="You" /><MessageHeader name="You" time="16:20" /><MessageBody><MessageBubble align="end">The second one. The arc feels like a harbour.</MessageBubble></MessageBody></Message><Message><MessageAvatar alt="Ada Lindqvist" /><MessageHeader name="Ada Lindqvist" time="16:31" /><MessageBody><MessageBubble>That was the idea. I’ll take it further tomorrow.</MessageBubble></MessageBody></Message><ThreadDay>Today</ThreadDay><Message><MessageAvatar alt="Ada Lindqvist" /><MessageHeader name="Ada Lindqvist" time="09:40" dateTime="2026-09-30T09:40" /><MessageBody><MessageBubble>The Halden marks are ready for review.</MessageBubble></MessageBody></Message><Message from="you"><MessageAvatar alt="You" /><MessageHeader name="You" time="09:44" dateTime="2026-09-30T09:44" /><MessageBody><MessageBubble align="end">Looking now. Can we see it at stamp size too?</MessageBubble></MessageBody><MessageFooter><MessageStatus read /></MessageFooter></Message>{Array.from({ length: arrived }, (_, i) => (<Message key={i} arriving><MessageAvatar alt="Ada Lindqvist" /><MessageHeader name="Ada Lindqvist" time={time(i)} /><MessageBody><MessageBubble>{updates[i % updates.length]}</MessageBubble></MessageBody></Message>))}</Thread><Button variant="bracket" onClick={() => setArrived((n) => n + 1)}>Ask Ada for an update</Button><p className="ot-pp">Scroll up, then ask Ada for an update: the thread stays where you are and counts what arrived.</p></div><div className="grid max-w-[64rem] gap-(--ot-space-7) lg:grid-cols-2"><Thread label="Proofs thread" variant="rests" className="h-[34rem]"><Lines lines={proofs} /></Thread><Thread label="Halden week" variant="running" className="h-[34rem]"><Lines lines={week} /></Thread></div></div>)}Contract
- Underneath: native, with the scrollbar.
- Anatomy:
.ot-threadholding.ot-thread-scroll(role="log",data-lenis-prevent) of messages and markers, and<button class="ot-thread-latest" hidden>holding.ot-thread-new. - It keeps to the latest message while you're at the end. Scrolled back, it stops following, and new messages are counted in an ink pill whose count rolls; pressing it focuses the log before taking you down, so focus stays there when the button disappears. Content sits at the bottom (
align-content: safe end) while the thread is short. - Variants on
.ot-thread(data-variant="rests | running"). - Rests, after the principle that silence is structure and the score's multi-bar rest. The space before each message is the silence before it: script reads each header's
<time dateTime>and sets--ot-restto log2(1 + minutes), and the gap is--ot-rest × 1.25 × --ot-space-2on a base of--ot-space-3, capped at--ot-space-9. Quick replies sit close; a morning's wait opens wide. A pause of an hour or more (data-rest="3 hours later") is said in that silence as the marker's lapse says it: the words in pencil atpp, centred (::after, aria-hidden), their letters spread by--ot-lapse, the lapse's own scale from a minute to a week. The two share one voice for a pause: the lapse is a marker you place, rests measures every gap for you. A day divider starts the count again. Nothing moves. - Running, after a book's running head and the SHAPES / GRADIENTS frame row. Scrolled back past a day's divider, the top edge carries
.ot-thread-head(aria-hidden, since the log says it all): the day at the start, the time of the first message in view at the end, a hairline between them. It hides while the day's own divider is in view. When you cross into another day the word rolls, up going on, down going back.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
Thread label | string | Names the log region, such as Halden thread. It is a role="log" that takes focus, so the keyboard can scroll it and screen readers hear each new message. | |
Thread variant | "default" | "rests" | "running" | "default" | rests: the space before each message grows with the minutes since the one before, read from its MessageHeader dateTime; a pause of an hour or more is said in that silence as the marker's lapse says it ("3 hours later"), in pencil, its letters spread by how long it went quiet. A ThreadDay starts the count again. running: scrolled back past a day's divider, the top edge carries a running head, the day and the time of the first message in view on a hairline between them; the day rolls as you cross into another. It is aria-hidden, since the log says it already. |
Thread newLabel | string | "new" | The word after the count in the pill: 2 new. |
Thread children | ReactNode | Messages and day dividers as direct children. Only messages that aren't yours are counted; sending one of your own takes you down. | |
Height | className | 26rem | Give the thread a height with className; the scroller fills it, and short threads sit at the bottom. |
ThreadDay | Marker props | Where a day begins: a word with a rule drawn out to each side. |
Craft
Where its move comes from
| Component | Reference | Move |
|---|---|---|
| Message scroller | WOVE | The new count rolls in an ink pill |
| Rests thread | "Silence is structure"; the score's multi-bar rest; the marker's lapse | The gap is the time between; a long pause is said in it as a lapse |
| Running thread | A book's running head; SHAPES / GRADIENTS frame row | Scrolled back, the day and time ride the top edge on a hairline |
How it moves
| Component | Articulation | What moves |
|---|---|---|
| Message scroller | Spiccato | The new-messages pill lands; its count rolls |
| Running thread | Roll | Crossing into another day, the running head's day rolls |