Reasoning Timeline
A thumb-scrubbed reasoning chain for phones — the running node is called out on an inverted card, resolved nodes fold into a count, and the window never becomes a nested scroller.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/reasoning-timeline.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ReasoningTimeline" component: an agent's
reasoning chain as a node timeline, sized for a 390px phone. lucide-react for
glyphs, cn() (clsx + tailwind-merge) for classes, Pointer Events for the gesture.
No animation library, no gesture library, no scroll container.
The one idea to hold on to: on a phone this cannot be a long column you scroll.
A twelve-node chain inside a chat message would become a nested scroll region,
and a nested scroller under a thumb turns every drag into a coin toss between the
inner list and the page. So the component renders a WINDOW around the node that
matters and folds the rest into counts, and the thing that depicts progress — the
rail — is also the thing you drag.
Contract
- export const ReasoningTimeline = React.forwardRef<HTMLDivElement,
ReasoningTimelineProps>, extending React.HTMLAttributes<HTMLDivElement>;
remaining props spread onto the root. Default export too.
- ReasoningStep = { id: string; label: string; detail?: React.ReactNode;
state?: "pending" | "active" | "done" | "error" | "skipped"; durationMs?: number }.
`state` defaults to "pending". `detail` is a slot — never import a markdown or
token-streaming renderer, let the caller pass one.
- steps: ReasoningStep[] is the only data input. There is NO `liveIndex` prop:
where the run stands is derived from the states, so the caller has one source
of truth and the two can never drift. liveIndexOf(steps) = the first node that
is "active" or "error", else the furthest node that is not "pending", else 0.
- Cursor (the called-out node) is controlled + uncontrolled: index?: number,
defaultIndex?: number (defaults to the live node), onIndexChange?(index).
Controlled means the consumer owns follow-the-run entirely.
- variant?: "rail" | "focus" | "dock" (default "rail") — three genuinely
different presentations, not colour swaps:
rail = vertical chain, one neighbour each side, draggable 44px node rail.
focus = one node only, bigger card, segmented track above it, next node named
underneath. The shape for a screen a software keyboard has halved.
dock = one line on the bottom edge above the composer: track, dot, label,
controls. Drops `detail` on purpose — a dock is a status line.
- context?: number (default 1, clamped 0..4) — neighbours per side in `rail`.
Ignored by focus / dock; say so in its JSDoc.
- showControls?: boolean (default true) — the thumb bar. Turn it off when the app
hosts previous / next / jump in its own bottom bar and drives `index`.
- label?, cursorLabel?, emptyLabel?: the three translatable strings.
- durationMs is caller-supplied and the component NEVER calls Date.now(): a wall
clock read during render makes the server and the first client frame disagree.
Formatting: <1s "740ms", <1min "2.7s", else "1m 4s"; non-finite or negative
renders nothing.
Behavior
- Windowing (rail): render steps[index - context .. index + context]. Anything
before becomes one static line "3 earlier steps · 4.1s" (the sum of their
supplied durations); anything after becomes "2 more steps". Those summaries are
TEXT, not buttons — a jump affordance there would unmount itself the moment it
ran out of steps to fold, taking the focus ring with it.
- The rail is the scrubber. Press anywhere on the 44px rail column (or, in
focus / dock, on the segmented track) and drag: one node per 32px of travel,
truncated rather than rounded, so the dead zone around the press is a whole
step and a tap never scrubs. Movement is measured from the index at press time,
never accumulated, so a drag out and back lands exactly where it started.
- Pointer Events only — one code path for finger, pen and mouse. On pointerdown:
ignore non-primary mouse buttons, ignore chains shorter than two nodes, focus
the track (a native slider does the same, and doing it BEFORE the window can
shift is what stops a focused step button from being scrubbed out from under
the focus ring onto <body>), then setPointerCapture on that same element and
release it there on pointerup / pointercancel and on unmount.
- touch-action does the negotiating, never preventDefault: `touch-none` on the
vertical rail (a vertical drag there is ours), `touch-pan-y` on the horizontal
track (the page keeps its vertical scroll; if the browser takes the gesture it
sends pointercancel and the drag ends cleanly).
- Follow, takeover, re-arm. While the reader has not moved the cursor, the window
follows the run: when the derived live index changes the cursor moves with it.
Any manual move (drag, tap on a node, arrow key, prev / next) parks the window
and lights up the jump-to-current button. Landing back on the live node re-arms
following by itself. A run that REWINDS (live index goes backwards, i.e. the
caller started a new chain) cancels the takeover — the old cursor belonged to
the old chain. Do this as a render-phase state adjustment compared against a
`prevLive` state, not in an effect: an effect paints one frame on the stale
node first, and set-state-in-effect is a lint error in most modern configs.
- Every gesture has an equal button and key path. Thumb bar: previous, "3 / 8" in
tabular-nums, next, and jump-to-current. The track is role="slider":
ArrowUp / ArrowLeft = earlier, ArrowDown / ArrowRight = later (one map that
matches what is on screen in both orientations), PageUp / PageDown = one window
(max(3, context * 2 + 1)), Home / End = first / last. Keydown preventDefaults
only the keys it handles.
- Never the native `disabled` attribute. At either end of the chain, on an empty
chain, and on the jump button while it is already following, use aria-disabled
plus a guard at the top of the handler — the browser blurs a node the instant
it becomes disabled, and the user may be standing on it. The jump button stays
mounted even with nothing to do, and re-arms following explicitly, so it is
never a live-looking control that does nothing.
- Two announcements, two owners. A polite role="status" region carries the RUN
("Step 3 of 8: Reading the baggage rules, running") and changes only when the
chain does. The reader's own cursor rides on the slider's aria-valuetext. That
split is what keeps a scrub silent instead of a screen-reader storm.
- Edge cases: empty steps renders emptyLabel with the controls still mounted and
aria-disabled; one step disables the slider and both arrows; the cursor is
clamped at read time so a chain that shrinks underneath it cannot point past
the end; a long label truncates on a neighbour and clamps to two lines when
called out; `detail` renders only for the called-out node in rail / focus.
- Cleanup: there are no timers, no rAF, no observers and no clocks by design —
every transition is a discrete state change. The only thing to release is the
pointer capture, in a mount-scoped effect cleanup.
Rendering & styling
- Semantic tokens only, no colour literals: bg-card, bg-background, bg-muted,
bg-border, text-foreground, text-muted-foreground, border, ring-ring, and
bg-destructive / text-destructive-foreground.
- The called-out node INVERTS (bg-foreground / text-background) instead of taking
a colour — that is the whole hierarchy. Colour is spent in exactly one place: a
failed node, which also carries the word "failed" so colour is never the only
signal. Neighbours are muted one-liners; queued ones one shade lighter still.
- Surfaces: root card rounded-2xl border bg-card (rail p-3, focus p-4); the node
card rounded-lg; the dock rounded-t-2xl border-t bg-card/95 backdrop-blur.
Type ladder: label 13px/600 (14px in focus), detail 12–13px, meta and counts
10–11px in tabular-nums.
- Rail geometry: rows are flex, the rail cell is w-11 and full row height, the
dot sits 20px from the row top and the connector is drawn as two halves (top-0
h-5 above it, top-5 bottom-0 below) so neighbouring rows meet without a seam.
The scrub surface is one absolutely positioned span on that column, rendered
first so the tab order matches the visual one; the dot cells above it are
aria-hidden and pointer-events-none, so they paint on top while the press still
lands on the scrubber. The step buttons start where the column ends.
- Safe area: only `dock` touches a screen edge, so only `dock` pays for it —
paddingBottom: max(var(--safe-area-inset-bottom, env(safe-area-inset-bottom,
0px)), 0.5rem). The custom property first, so a device frame or a test can
simulate a home indicator on hardware that reports 0.
- Touch: every control is size-11 (44px), the node rows are min-h-11, the rail is
44px wide. Nothing depends on hover.
- prefers-reduced-motion: the only animation is the halo pulsing on the running
dot / segment (motion-reduce:animate-none) and colour transitions. The window
moves as a jump cut on purpose — content sliding under a resting thumb reads
worse than no animation at all — so with motion off nothing is lost.
- Accessibility: root role="group" + aria-label, aria-busy while a node is
running; the list is an <ol role="list"> (list-style:none drops list semantics
in some browsers). Where a node is selectable at all (rail), it is a button in
EVERY state, called out or not — swapping the element type at the moment a node
becomes the called-out one would unmount a focused button; it carries
aria-current="step" when called out and an aria-label of "Step 3 of 8: <label>,
running". In focus the node on screen is always the called-out one, so it is a
plain row instead of a button that could only select what is already selected.
focus-visible rings on the slider, the rows and every control.
Customization levers
- Density: `context` 0 (just the called-out node) … 4; row padding py-2.5 and the
1.5 gap below each row are the two numbers that set the card's height budget.
- Physics: SCRUB_STEP_PX (32) is the whole feel of the drag — 24 makes it flighty
on a long chain, 48 makes a twelve-node chain a two-swipe job. MIN_PAGE (3) is
the PageUp / PageDown floor.
- Structure: drop the thumb bar with showControls={false} and drive `index` from
your own bottom bar; drop `detail` from the steps for a pure progress rail; add
a per-step icon slot beside the dot if your tools deserve glyphs.
- Presentation: the three variants are the intended fork. A fourth is a small
change — reuse the window and the slider, replace the row rendering only.
- Palette: swap the inverted callout for bg-primary / text-primary-foreground if
the product wants its brand on the active node; keep destructive for failures.
- Vocabulary: STATE_WORD is the five announced words; the duration formatter and
the two fold-up sentences are the rest of the copy.Concepts
- Windowed, not scrolled — the chain is bounded by rendering
index ± contextand folding the rest into counts, so a twelve-node run never grows a nested scroll region for the thumb to fight, and the card's height stays put while the run advances. - Rail as scrubber — the column that draws the progress is the surface you drag: 32px of pointer-captured travel per node, truncated so a wobble moves nothing, with
touch-actiontelling the browser which axis it may keep instead of apreventDefaultthat a passive listener would refuse. - Follow, takeover, re-arm — the window rides the run until the reader moves the cursor, parks the moment they do, and re-arms by itself when the cursor lands back on the live node; a rewound run cancels the takeover, because that cursor belonged to the previous chain.
- Called-out node — hierarchy comes from inversion (
bg-foreground/text-background) and from a taller track segment, never from a tint; the single colour in the component marks a failure, and the word “failed” marks it again for anyone who cannot see the colour. - Injected duration — every elapsed number arrives in
durationMsfrom the caller, so nothing reads a wall clock during render and the server, the hydration frame and the screenshot all agree. - Thumb-bar parity — every gesture has a visible button and a key: previous, next and jump-to-current sit in the thumb arc, the rail answers the arrow keys, and the jump button stays mounted,
aria-disabled, rather than vanishing under the finger that just used it.
Criteria Checklist
A live rule-satisfaction panel for a field being typed on a phone: a row per rule, a two-column grid, or a one-line summary of the next thing to fix — chosen from the keyboard height the visual viewport actually reports.
Chat Message Actions
Hold one chat bubble and its quick reactions plus that message's actions come up against it — as an anchored menu, a compact toolbar or a sheet from the bottom edge.