AI Branch Switcher
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.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add ai-branch-switcher.enAI Branch Switcher is a React component for switching between regenerated versions of an AI chat reply, the "‹ 2 / 3 ›" pager you see after regenerating an answer in a chat assistant. It is built with Tailwind CSS and Motion, with icons from lucide-react; the component handles display and switching while your code owns the data and requests.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<AiBranchSwitcher/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- AI chat products where users compare several regenerated answers to the same prompt
- Copy, headline or email draft generators that keep every output so users can pick one
- Support tools where an agent flips between suggested replies before sending
- Model comparisons that keep answers from different models in one bubble (label each with the
modelfield)
Installation
npx webberui@latest add https://webberui.com/r/ai-branch-switcher.en.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: "【Professional Version】……" },
{ id: "v2", model: "Aurora-7", createdAt: "09:13", content: "【Casual version】……" },
]);
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);
}}
/>Examples
Uncontrolled with compact mode
If you do not need to track which version is showing, pass only versions and defaultIndex; compact suits sidebars and narrow chat panels.
<AiBranchSwitcher
compact
defaultIndex={0}
versions={[
{ id: "a1", model: "Aurora-7", createdAt: "14:32", content: "Yes, you can return items within 7 days of delivery." },
{ id: "a2", model: "Aurora-7", createdAt: "14:33", content: "Returns are accepted within 7 days of delivery. Start one from My Orders." },
]}
/>In uncontrolled mode, when versions grows the component jumps to the newest version and fires onIndexChange.
Custom content rendering
renderContent lets you swap in your own renderer; to keep the default paragraph styling, call the named export renderPlainParagraphs inside it.
import {
AiBranchSwitcher,
renderPlainParagraphs,
} from "@/components/ui/ai-branch-switcher";
<AiBranchSwitcher
versions={versions}
renderContent={(version) => (
<div className="space-y-3 text-sm">
{renderPlainParagraphs(version.content)}
{version.model === "Aurora-7-mini" && (
<p className="text-xs text-neutral-500">Generated by the lightweight model</p>
)}
</div>
)}
/>Rewriting the UI labels
labels accepts a partial override; position is a function used by both the pager and the screen reader announcement.
<AiBranchSwitcher
versions={versions}
onRegenerate={handleRegenerate}
regenerateLabel="Rephrase"
labels={{
copied: "Copied to clipboard",
position: (current, total) => `Answer ${current} of ${total}`,
}}
/>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" (Previous version) | Accessible label of the "previous" button |
next | string | "Next version" (Next version) | Accessible label of the "next" button |
copy | string | "Copy this version" (Copy this version) | Accessible label of the copy button |
copied | string | "Copied" (Copied) | Text shown briefly after a successful copy |
generating | string | "Generating a new version" (Generating a new version) | Generating hint (under the skeleton and in the aria-live announcement) |
tree | string | "Response versions" | Accessible label of the branch tree region |
empty | string | "No reverted versions yet" (No versions yet) | Text shown when there are no versions |
position | (current: number, total: number) => string | Version ${current} of ${total} | 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="Response version"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
FAQ
Does the component call an AI API itself?
No. The regenerate button only calls onRegenerate; the request, streaming and pushing the new version into versions are up to you. Set isGenerating to true during the request to show the typing skeleton.
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so import it from a Server Component page as usual. The reduced-motion check is applied only after mount, so the first render matches the SSR output.
Can it render Markdown?
By default it splits plain text into paragraphs on blank lines. To render Markdown, plug the Markdown renderer your project already uses into renderContent.
When does the copy button not work?
Copying uses navigator.clipboard.writeText, which fails silently on non-HTTPS pages or when the user denies permission; the text can still be selected by hand.
What are the dependencies?
motion, lucide-react, clsx and tailwind-merge, plus cn from @/lib/utils. The CLI handles this for you; without the CLI, copy the source from the Manual tab.
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
Chat Scroller
A scroll container for chat: it follows streaming output while pinned to the bottom, shows a new-messages pill when you scroll away, keeps position when loading history, and can scroll to any message on demand.