AI Interface Components

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.en

New project or installation trouble? Start here →

AI 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.

Active edited usageDefault props
<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 model field)

Installation

npx webberui@latest add https://webberui.com/r/ai-branch-switcher.en.json

Or, 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

PropTypeDefaultDescription
versionsBranchVersion[]—Every version (branch) of the same reply, in generation order
indexnumber—Controlled current index (0-based); when provided the parent owns it
defaultIndexnumber0Initial 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
isGeneratingbooleanfalseWhether 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
showTreebooleanfalseShow the mini branch tree above the bubble (one main line with a small dot per version, each clickable)
compactbooleanfalseCompact 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
regenerateLabelstring—"Regenerate" button text; a shortcut for labels.regenerate and takes precedence when both are given
labelsPartial<AiBranchSwitcherLabels>—UI copy; override any subset
classNamestring—Forwarded to the outermost container

BranchVersion

FieldTypeDefaultDescription
idstring—Unique id, used as the React key and the boundary of the slide animation
contentstring—Reply content; rendered as plain-text paragraphs (split on blank lines) by default, or via renderContent
createdAtstring—Display text for the generation time (e.g. "14:32"); the component does no timezone conversion and shows it as-is
modelstring—Name of the model that produced this version, shown as a small caption on the right of the toolbar

AiBranchSwitcherLabels

FieldTypeDefaultDescription
regeneratestring"Regenerate""Regenerate" button text
previousstring"Previous version" (Previous version)Accessible label of the "previous" button
nextstring"Next version" (Next version)Accessible label of the "next" button
copystring"Copy this version" (Copy this version)Accessible label of the copy button
copiedstring"Copied" (Copied)Text shown briefly after a successful copy
generatingstring"Generating a new version" (Generating a new version)Generating hint (under the skeleton and in the aria-live announcement)
treestring"Response versions"Accessible label of the branch tree region
emptystring"No reverted versions yet" (No versions yet)Text shown when there are no versions
position(current: number, total: number) => stringVersion ${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 index changes 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. Set isGenerating to true while the request runs (the content area becomes a skeleton and the pager shows a reserved "4 / 4" slot), then in one batch push into versions, point index at the last item, and set isGenerating back to false; the new version slides in from the right.
  • Uncontrolled auto-follow: without an index prop, whenever versions grows the component switches to the newest version and fires onIndexChange; in controlled mode the parent decides whether to jump.
  • Content height: switching uses AnimatePresence in popLayout mode, so the old and new versions coexist and the exiting one is clipped by overflow-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: createdAt is 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.writeText and 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" with aria-roledescription="Response version" and an aria-label of the current position ("Version 2 of 3"); the whole bubble is focusable (tabIndex=0) with a focus-visible ring, 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") with aria-labels (localize via labels.previous/labels.next) and are disabled at 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 button whose aria-label is that version's position text, with aria-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 and labels.generating while a new branch is generating; the bubble gets aria-busy while 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.