AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Entrance keyframes
 *
 * They ship with the component through a React 19 hoisted <style> — no Tailwind
 * config edit, and a transcript with fifty branched messages dedupes them by
 * href down to one rule set.
 *
 * The animation is attached to the variant being SHOWN and never to the one

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/message-branch.json

Prompt

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-disabled ends — a native disabled blurs 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 the hidden attribute when a variant is still streaming — hidden also leaves the accessibility tree, find-in-page and the tab order, which opacity: 0 does not.

On This Page