XI Conversation

Star on GitHubFeedback
XI ConversationNative HTMLot-thread

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 Thread

Example

Yesterday

Ada Lindqvist

First round of marks is up. Three directions, one arc.

You

The second one. The arc feels like a harbour.

Ada Lindqvist

That was the idea. I’ll take it further tomorrow.

Today

Ada Lindqvist

The Halden marks are ready for review.

You

Looking now. Can we see it at stamp size too?

Read

Scroll up, then ask Ada for an update: the thread stays where you are and counts what arrived.

Ada Lindqvist

The proofs are back from the printer.

Ada Lindqvist

The blue ran on page four.

You

Can you send a photo?

Ada Lindqvist

It's in the drive.

You

Seen it. We reprint four.

Ada Lindqvist

I'll call them now.

Monday

Ada Lindqvist

Brief is signed. We start Wednesday.

You

Good. Keep the harbour in mind.

Yesterday

Ada Lindqvist

First round of marks is up. Three directions, one arc.

You

The second one. The arc feels like a harbour.

Today

Ada Lindqvist

The Halden marks are ready for review.

You

Looking now. Can we see it at stamp size too?

Ada Lindqvist

The anchor fills in at stamp size. The flag holds.

Rests, quick replies
Ada Lindqvist

The proofs are back from the printer.

Ada Lindqvist

The blue ran on page four.

You

Can you send a photo?

Rests, a long pause
Ada Lindqvist

It's in the drive.

You

Seen it. We reprint four.

Running head, scrolled back

Yesterday

Ada Lindqvist

First round of marks is up. Three directions, one arc.

You

The second one. The arc feels like a harbour.

Today

Ada Lindqvist

The Halden marks are ready for review.

You

Looking now. Can we see it at stamp size too?

Ada Lindqvist

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

Usage

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
thread-example.tsx
"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&rsquo;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-thread holding .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-rest to log2(1 + minutes), and the gap is --ot-rest × 1.25 × --ot-space-2 on 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 at pp, 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

Props of Thread
PropTypeDefaultWhat it does
Thread labelstringNames 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 newLabelstring"new"The word after the count in the pill: 2 new.
Thread childrenReactNodeMessages and day dividers as direct children. Only messages that aren't yours are counted; sending one of your own takes you down.
HeightclassName26remGive the thread a height with className; the scroller fills it, and short threads sit at the bottom.
ThreadDayMarker propsWhere a day begins: a word with a rule drawn out to each side.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
Message scrollerWOVEThe new count rolls in an ink pill
Rests thread"Silence is structure"; the score's multi-bar rest; the marker's lapseThe gap is the time between; a long pause is said in it as a lapse
Running threadA book's running head; SHAPES / GRADIENTS frame rowScrolled back, the day and time ride the top edge on a hairline

How it moves

How it moves
ComponentArticulationWhat moves
Message scrollerSpiccatoThe new-messages pill lands; its count rolls
Running threadRollCrossing into another day, the running head's day rolls