Message Branch
A compact pager for the sibling variants of one reply — an id-stable selection that survives late arrivals, follow-the-tip on regenerate, ends that never steal focus, and one announcement per move.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/message-branch.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MessageBranch" component (lucide-react for the two
chevrons, the project's cn() helper, nothing else). It is the `‹ 2/3 ›` pager that sits with
ONE chat message and swaps between the sibling variants "Regenerate" produced. It does not
produce variants and it does not restore conversations — it only chooses which sibling shows.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus children;
the rest of the props spread onto the root.
- branches: { id: string; content: React.ReactNode; label?: string; pending?: boolean }[]
— the variants of one message, oldest first. `id` must be unique and stable: it is what
the selection is remembered by. `label` is a short caption (model name, "edited"), shown
next to the pager and read out. `pending` marks a variant whose tokens are still arriving.
- index?: number — controlled. Anything but undefined hands ownership to the
parent: the pager renders this index (clamped) and navigation
only calls back.
- defaultIndex?: number — uncontrolled start; defaults to the LAST branch, because a
regenerated reply opens on the newest variant.
- onBranchChange?(index, branch) — user-driven moves only (see Behavior).
- loop?: boolean — default false.
- followLatest?: boolean — default true, uncontrolled only.
- indicator?: "counter" | "dots" | "auto" (default "counter"), dotsMax?: number (default 5).
- mount?: "active" | "all" — default "active".
- align?: "start" | "end", placement?: "top" | "bottom" (default "start" / "bottom").
- hideWhenSingle?: boolean (default true), animate?: boolean (default true),
announce?: boolean (default true).
- label?: string — accessible name of the pager group, default "Response variants".
- itemNoun?: string — default "variant"; every label is built from it ("Previous
variant", "Variant 2 of 3"). formatStatus?(index, total, branch)
overrides the whole sentence for translation.
- controls?: React.ReactNode — extra nodes appended to the pager row (Regenerate, a token
count, a timestamp). The component never invents such actions.
- branches: [] renders null. Never a 0/0 pager, never an empty shell.
Behavior — the selection is an id, not a number
- Keep { index, id, sig, count } in one state object, where `sig` is the branch ids joined.
When `sig` changes, reconcile DURING RENDER (not in an effect — an effect paints one wrong
frame first, and a deleted variant would flash as a blank message):
1. followLatest && the list grew && the reader was on the old last index → jump to the new
last. That is what Regenerate should feel like.
2. else if the remembered id is still present → take its NEW index. A variant deleted above
the current one must not slide a different answer under the reader.
3. else (the branch itself is gone) → clamp the old index into range.
- Controlled mode skips 1–3 entirely: a parent that owns the index already knows what it
appended. Clamp its value for rendering, never "correct" it through onBranchChange.
- onBranchChange fires for arrows, dots and keys only. The automatic re-selection above is
display-only and silent, so a consumer that does setState in the callback can never loop
with a `branches` array rebuilt on every render.
Behavior — moving
- Prev/Next step by one. With loop the index wraps modulo the count; without it the handler
returns early at the ends.
- At an end (loop off), the arrow gets aria-disabled="true" plus that early return — NEVER
the native `disabled` attribute. The browser blurs a focused element the instant it becomes
disabled, so a keyboard user pressing Next until the last variant would drop focus on
<body> and could not press Prev to come back. No pointer-events:none either — the button
stays focusable and announced, it just drops its hover classes (dimming through opacity)
rather than stacking a second rule to fight them.
- Keyboard, scoped to the pager group so the transcript keeps its own arrows: ArrowLeft /
ArrowRight step, Home / End jump to the first / last variant. Bail out when any modifier is
held (Alt+Left is browser Back) and when the event target is an input, textarea, select or
contenteditable inside `controls`. preventDefault ONLY for keys that were handled.
- Every move bumps a monotonic `seq` and records a direction ("forward" / "back") in the same
state object, so the announcement and the animation can never disagree.
Behavior — indicator
- "counter": `2/3` in tabular-nums, with a min-width of (digits*2 + 1)ch reserved so stepping
9 → 10 cannot shift the arrows sideways under the cursor.
- "dots": one button per variant, aria-current on the active one, 6px of ink inside a 24px
hit target (WCAG 2.2 target size is the hit area, not the dot). The dots are ONE tab stop
with a roving tabindex (tabIndex 0 on the active dot, -1 on the rest) — a homogeneous set
is a composite widget, and 12 extra tab stops per message would drown a transcript. When a
keyboard move originates from a dot, move focus to the dot that took over, otherwise focus
is stranded on a control that just left the tab order. Clicking an arrow must NOT steal
focus into the dots.
- "auto": dots up to dotsMax, counter above it. Twelve dots is a decoration, not a control.
Behavior — screen readers
- The pager is role="group" with an accessible name. One sr-only <span id> holds the sentence
("Variant 2 of 3, gpt-4o") and both arrows point at it with aria-describedby, so tabbing
onto an arrow says where you are; the visible `2/3` is aria-hidden, because "two slash
three" is not a sentence.
- A separate sr-only role="status" aria-live="polite" aria-atomic="true" region announces each
move. Its child is KEYED BY the move counter: 2 → 3 → 2 produces the same string twice and a
live region whose text did not change is silently not announced — a new node forces it.
It stays empty at seq 0, so scrolling back through a transcript announces nothing.
Behavior — mounting and animation
- mount="active" renders only the current variant, keyed by branch id.
- mount="all" renders every variant and hides the inactive ones with the `hidden` attribute
— which, unlike visibility/opacity, also removes them from the accessibility tree, from
find-in-page and from the tab order. Use it when a variant is still streaming or holds a
scrolled code block; it costs one mounted subtree per variant.
- The entrance animation is attached to the ARRIVING variant only, never a crossfade: two
variants in the layout at once double the message height for the duration and shove the
rest of the transcript down and back. Forward slides in from the right, back from the left.
- Ship the keyframes with the component in a React 19 hoisted <style href precedence>, so
there is no Tailwind config edit and fifty branched messages dedupe to one rule set. Under
mount="all" the display:none → visible transition restarts the animation for free; under
mount="active" the changed key does. Nothing imperative, no timer, no RAF, nothing to clean
up on unmount.
- Skip the animation at seq 0 (a message must not slide in when it first paints) and always
under prefers-reduced-motion via motion-reduce:[animation:none] — switching still works.
Rendering & styling
- Semantic tokens only: text-muted-foreground, hover:bg-accent / hover:text-accent-foreground,
bg-primary for the active dot and the pending pulse, bg-muted-foreground/40 for idle dots,
border / bg-card in the surrounding chrome, focus-visible:ring-2 ring-ring. No hex, rgb()
or oklch().
- cn() merges the consumer className onto the root. The root is flex-col with min-w-0; the
pager row is min-w-0 too and the variant label is truncate + title, so a long model name
shrinks instead of pushing the arrows out of a narrow column.
- data-branch-count / data-branch-index on the root and data-branch-id / data-direction on the
variant wrapper, so a consumer can style or test against the state without reading React.
Customization levers
- Indicator: "counter" for chat density, "dots" for two-to-five variants, "auto" + dotsMax to
let the count decide. Restyle the dot in one span; swap the counter for "2 of 3" in one line.
- Ends: loop for a carousel feel, default clamp for a list feel. Keep aria-disabled either way.
- Placement/alignment: placement="top" puts the pager above the bubble (good when the message
is long), align="end" mirrors it for the reader's own right-aligned messages.
- Wording: itemNoun retitles every label at once ("edit", "draft", "take"); formatStatus takes
over the whole sentence for i18n. For RTL, swap the ArrowLeft/ArrowRight branches and the
two entrance keyframes — the logic is direction-agnostic, the two key names are not.
- Composition: `controls` is the seam for Regenerate, a timestamp or a token count; keep the
action itself in your message action bar so the pager stays a navigator.
- Density: size-6 buttons and gap-0.5 are the only two knobs for the row; text-xs on the
counter and the label is the third.
- Performance: mount="active" is the default for a reason — switch to "all" only for the
messages that need keep-alive, not for the whole transcript.Concepts
- Id-stable selection — the pager remembers which branch it is on by
id, not by slot, and re-derives the index whenever the array changes. Deleting the variant above the one being read, or reordering the list, moves the pointer with the answer instead of sliding a different answer under the reader. - Follow the tip — appending a variant while the reader is on the newest one carries them forward (that is what pressing Regenerate should feel like); appending while they have paged back to compare leaves them exactly where they were. Two behaviours from one rule: "was the reader at the end before this arrived?"
aria-disabledends — a nativedisabledblurs the element the moment it applies, so pressing Next to the last variant would drop a keyboard user on<body>with no way back. The end arrows stay focusable and hoverable and merely refuse to act.- One announcement per move — the position sentence is written into a polite live region as a node keyed by a move counter, because a live region whose text is unchanged is silently not announced; without the key, going 2 → 3 → 2 would speak twice instead of three times. Mounting is counter 0, so a transcript scrolled back through stays quiet.
- Roving dots — the dot strip is one tab stop with arrow keys inside it, and a keyboard move carries focus to the dot that took over; giving every dot its own tab stop would add a dozen stops per message, and moving without carrying focus would strand it on a control that just left the tab order.
- Swap in place, keep-alive optional — only the arriving variant animates and the message never holds two variants at once, so the transcript under it does not jump.
mount="all"swaps unmounting for thehiddenattribute when a variant is still streaming — hidden also leaves the accessibility tree, find-in-page and the tab order, whichopacity: 0does not.
Prompt Library
A saved-prompt library with fill-in-the-blank templates — search across title, body and tags, faceted categories, a variable form ordered by first appearance, and a preview that is character-for-character what gets inserted.
Chat Day Divider
A muted separator for chat transcripts — day chip, unread watermark and context event — that derives Today/Yesterday from calendar days, relabels itself at local midnight, and can pin to the top while its day scrolls.