AI Branch Switcher (React)
A React version switcher for a single AI reply: a ‹ 2 / 3 › pager slides the content directionally, regenerate opens a new branch with a typing skeleton, an optional mini branch tree sits above the bubble, ←/→ switch versions while the bubble has focus, plus copy and model/time captions.
This keeps every version of one reply inside a single assistant bubble: the bottom toolbar holds a "‹ 2 / 3 ›" pager, regenerate and copy buttons, and a model/time caption on the right. Switching slides the content with AnimatePresence in the direction you travel — going forward the new version enters from the right while the old one exits left, going back does the reverse. Pressing regenerate calls onRegenerate; set isGenerating to true while you stream and the content area swaps to a typing skeleton with a pending slot reserved in the pager and branch tree, then push the new version into versions and it slides in from the right. Turn on showTree to draw a main line above the bubble with one small dot per version — the current one highlighted and each clickable. With focus inside the bubble, ←/→ (and Home/End) switch versions too. The index works controlled or uncontrolled.
npx shadcn@latest add https://webberui.com/r/ai-branch-switcher.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<AiBranchSwitcher />
Installation
npx shadcn@latest add https://webberui.com/r/ai-branch-switcher.jsonOr, once registries are configured in components.json, install it as @webberui/ai-branch-switcher.
Usage
import { AiBranchSwitcher, type BranchVersion } from "@/components/ui/ai-branch-switcher";
// Content and model names are fictional
const [versions, setVersions] = React.useState<BranchVersion[]>([
{ id: "v1", model: "Aurora-7", createdAt: "09:12", content: "【專業版】……" },
{ id: "v2", model: "Aurora-7", createdAt: "09:13", content: "【輕鬆版】……" },
]);
const [index, setIndex] = React.useState(1);
const [generating, setGenerating] = React.useState(false);
<AiBranchSwitcher
versions={versions}
index={index}
onIndexChange={setIndex}
isGenerating={generating}
showTree
onRegenerate={async () => {
setGenerating(true);
const content = await regenerate(); // your stream / request
// One batch: push the new version, jump the index to the end, stop generating
setVersions((prev) => [...prev, { id: crypto.randomUUID(), content, model: "Aurora-7" }]);
setIndex(versions.length);
setGenerating(false);
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
versions | BranchVersion[] | — | Every version (branch) of the same reply, in generation order |
index | number | — | Controlled current index (0-based); when provided the parent owns it |
defaultIndex | number | 0 | Initial index in uncontrolled mode (0-based) |
onIndexChange | (index: number) => void | — | Called when the index changes (pager, branch tree, keyboard, and when uncontrolled mode auto-follows a newly appended version) |
onRegenerate | () => void | — | Called when "Regenerate" is pressed; the button is hidden when omitted |
isGenerating | boolean | false | Whether a new branch not yet in versions is being generated: the content area shows a typing skeleton, the pager and branch tree reserve a pending slot, and the toolbar is disabled |
showTree | boolean | false | Show the mini branch tree above the bubble (one main line with a small dot per version, each clickable) |
compact | boolean | false | Compact mode: tighter padding, smaller text and toolbar |
renderContent | (version: BranchVersion) => React.ReactNode | — | Custom content renderer (e.g. Markdown); by default plain text is split into paragraphs on blank lines |
regenerateLabel | string | — | "Regenerate" button text; a shortcut for labels.regenerate and takes precedence when both are given |
labels | Partial<AiBranchSwitcherLabels> | — | UI copy; override any subset |
className | string | — | Forwarded to the outermost container |
BranchVersion
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique id, used as the React key and the boundary of the slide animation |
content | string | — | Reply content; rendered as plain-text paragraphs (split on blank lines) by default, or via renderContent |
createdAt | string | — | Display text for the generation time (e.g. "14:32"); the component does no timezone conversion and shows it as-is |
model | string | — | Name of the model that produced this version, shown as a small caption on the right of the toolbar |
AiBranchSwitcherLabels
| Field | Type | Default | Description |
|---|---|---|---|
regenerate | string | "重新生成" (Regenerate) | "Regenerate" button text |
previous | string | "上一個版本" (Previous version) | Accessible label of the "previous" button |
next | string | "下一個版本" (Next version) | Accessible label of the "next" button |
copy | string | "複製這個版本" (Copy this version) | Accessible label of the copy button |
copied | string | "已複製" (Copied) | Text shown briefly after a successful copy |
generating | string | "正在產生新的版本" (Generating a new version) | Generating hint (under the skeleton and in the aria-live announcement) |
tree | string | "回覆版本分支" (Reply version branches) | Accessible label of the branch tree region |
empty | string | "尚無回覆版本" (No versions yet) | Text shown when there are no versions |
position | (current: number, total: number) => string | 第 ${current} 個版本,共 ${total} 個 (Version 2 of 3) | Position text for the pager and the aria-live announcement |
clampBranchIndex(index, count) (clamps an index into the valid range) and renderPlainParagraphs(content) (the default plain-text paragraph renderer) are also named exports, so a custom renderContent can reuse the default paragraph styling.
How it works
- Direction: the slide direction is derived during render from the previous index, so controlled, uncontrolled, and external
indexchanges all animate correctly — new versions enter from the right and old ones exit left, reversed when going back. Starting a new generation always counts as "forward", so the skeleton and the outgoing content never enter and exit on the same side after you had just paged backwards. - Regenerate data flow: the component makes no requests — the button only calls
onRegenerate. SetisGeneratingtotruewhile the request runs (the content area becomes a skeleton and the pager shows a reserved "4 / 4" slot), then in one batch push intoversions, pointindexat the last item, and setisGeneratingback tofalse; the new version slides in from the right. - Uncontrolled auto-follow: without an
indexprop, wheneverversionsgrows the component switches to the newest version and firesonIndexChange; in controlled mode the parent decides whether to jump. - Content height: switching uses
AnimatePresenceinpopLayoutmode, so the old and new versions coexist and the exiting one is clipped byoverflow-hidden; the bubble height updates directly to the new content without a height animation, so versions of very different length never slow the switch down. - Time text:
createdAtis a display string — the component neither parses nor timezone-shifts it. Whether you show "14:32" or "yesterday 09:10" is up to you, and server and client output stay identical. - Copy: uses
navigator.clipboard.writeTextand fails silently on non-HTTPS pages or when permission is denied (the content can still be selected by hand); on success the icon becomes a check mark for 1.6 seconds.
Accessibility
- The bubble is a
role="group"witharia-roledescription="回覆版本"and anaria-labelof the current position ("Version 2 of 3"); the whole bubble is focusable (tabIndex=0) with afocus-visiblering, and while focus is inside it ←/→ move to the previous/next version and Home/End jump to the first/last - Both pager buttons are native
buttons (type="button") witharia-labels (localize vialabels.previous/labels.next) and aredisabledat the ends; the pager group itself also carries the position text, while the visible "2 / 3" is hidden from assistive tech to avoid double reading - Every branch-tree dot is a
buttonwhosearia-labelis that version's position text, witharia-current="true"on the current one; the whole tree is disabled while generating - A hidden
role="status",aria-live="polite"region announces the new position on every switch andlabels.generatingwhile a new branch is generating; the bubble getsaria-busywhile generating - When the user has "reduce motion" enabled at the system level, the horizontal slide, blur, digit roll, branch-tree ring movement, and skeleton shimmer are disabled and switching keeps only a fade
AI Attachment Tray
Attachment tray that mounts above any composer: drop or paste files into thumbnail chips with upload progress rings, retry on failure, type icons, limit hints and drag-to-reorder
Smooth Caret Input
An input with a spring-physics smooth caret — the custom caret glides to the text position on a spring, in bar, block, or underline shapes.