Skip to content
Star on GitHub
ConversationResponse versions
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Response versions

Regenerate an answer and flip between versions. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add response-versions
Write a headline for the pricing page

1 / 1
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add response-versions

React

$npx designme add response-versions --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Response versions shows an agent's answer with a small bar under it to page through earlier versions, regenerate it and copy it. Each regenerated answer is kept as a new version, so the person can compare it with the ones before and keep the one they like.

  • When people regenerate answers and want to go back to an earlier one instead of losing it.
  • When you want a new version to stream in where the old one was, with a counter like 2 / 3.
  • When an answer sits in a chat thread and needs copy and regenerate controls right under it.
03

Usage

Import it and drive it from your own state.

Import

import { ResponseVersions } from '@/components/designme/response-versions/ResponseVersions';

Example

The demo above as a React component, 50 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { ResponseVersions } from './ResponseVersions';
import { useScript, useTakeover, type Script } from '../lib/useScript';

/* ---------- Response versions: the first answer streams in, then one regenerate plays by itself; the arrows page between them ---------- */
const POOL = [
  'Pricing that grows with your team. Start free, upgrade when the work gets serious.',
  'One plan per stage of your team. No seat math, no surprises on the invoice.',
  'Design faster with an agent on every plan. Pay for what you ship, not for seats.',
  'Simple pricing for teams who ship. Free to start, fair as you grow.'
];
export default function ResponseVersionsDemo() {
  const [versions, setVersions] = useState<string[]>(['']);
  const [version, setVersion] = useState(0);
  const [streaming, setStreaming] = useState(true);
  const [status, setStatus] = useState('');
  const script = useRef<Script>(null);

  // writes a version word by word, the way an answer streams in
  const stream = async ({ wait }: Script, at: number, text: string) => {
    await wait(260);
    let out = '';
    for (const w of text.split(' ')) { out += (out ? ' ' : '') + w; const now = out; setVersions(v => v.map((x, i) => i === at ? now : x)); await wait(45); }
  };
  const ref = useRef<HTMLDivElement>(null), mine = useTakeover(ref), count = useRef(1);
  useScript(async s => {
    script.current = s;
    await s.wait(300); await stream(s, 0, POOL[0]); setStreaming(false);
    // then one regenerate plays by itself, so the version count and arrows show what they do, unless you have taken over
    await s.wait(1900); if (!mine() && count.current === 1) await regenerate(true);
  });
  const regenerate = async (auto = false) => {
    const s = script.current; if (!s || (streaming && !auto)) return;
    const at = count.current++;
    setStreaming(true); setStatus('Writing a new version');
    setVersions(v => [...v, '']); setVersion(at);
    await stream(s, at, POOL[at % POOL.length]);
    setStreaming(false); setStatus('');
  };

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <ResponseVersions prompt="Write a headline for the pricing page" versions={versions} version={version} onVersionChange={setVersion}
        streaming={streaming} status={status} onRegenerate={() => regenerate()} />
    </div>
  );
}
04

Props

Everything ResponseVersions takes.

PropTypeWhat it does
promptReactNodeThe person's message the answer replies to, shown as a bubble above it
versionsrequiredstring[]Every version of the answer so far, oldest first
versionnumberWhich version is showing. Leave it out to let the arrows page through them
defaultVersionnumber
onVersionChange(index: number) => void
streamingbooleanThe version showing is still being written. New words fade in as they arrive and paging waits
statusstringQuiet text beside the buttons, like "Writing a new version". It shimmers while streaming
onRegenerate() => voidCalled by the regenerate button. Add a version and show it to answer it
onCopy(text: string) => voidCalled with the text after it is copied to the clipboard
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

ResponseVersions.tsxReact · 82 lines
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useCopied, useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap, Tip } from '../lib/motion';
import '../designme.css';
import './response-versions.css';

export interface ResponseVersionsProps {
  /** The person's message the answer replies to, shown as a bubble above it */
  prompt?: ReactNode;
  /** Every version of the answer so far, oldest first */
  versions: string[];
  /** Which version is showing. Leave it out to let the arrows page through them */
  version?: number;
  defaultVersion?: number;
  onVersionChange?: (index: number) => void;
  /** The version showing is still being written. New words fade in as they arrive and paging waits */
  streaming?: boolean;
  /** Quiet text beside the buttons, like "Writing a new version". It shimmers while streaming */
  status?: string;
  /** Called by the regenerate button. Add a version and show it to answer it */
  onRegenerate?: () => void;
  /** Called with the text after it is copied to the clipboard */
  onCopy?: (text: string) => void;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

/** An answer with its earlier versions a click away, plus regenerate and copy. */
export function ResponseVersions({ prompt, versions, version, defaultVersion = 0, onVersionChange, streaming = false, status = '', onRegenerate, onCopy, bare = true, className }: ResponseVersionsProps) {
  const [cur, setCur] = useControllable(version, defaultVersion, onVersionChange);
  const reduce = useReducedMotion();
  const last = Math.max(0, versions.length - 1), at = Math.min(Math.max(cur, 0), last);

  // a different version fades the old text out first; then a finished one comes in all at once, word by word,
  // and one still being written shows its words as they arrive
  const [shown, setShown] = useState(at);
  const [out, setOut] = useState(false);
  const [jump, setJump] = useState(false);
  useEffect(() => {
    if (at === shown) return;
    setOut(true);
    const t = setTimeout(() => { setShown(at); setJump(!streaming); setOut(false); }, reduce ? 0 : 240);
    return () => clearTimeout(t);
  }, [at, shown, streaming, reduce]);
  const text = versions[shown] ?? '';
  const words = text.split(' ').filter(Boolean);

  const [spin, setSpin] = useState(false);
  const regenerate = () => {
    if (streaming) return;
    setSpin(true); setTimeout(() => setSpin(false), 700);
    onRegenerate?.();
  };
  const [copied, copyText] = useCopied();
  const copy = () => { const t = versions[at] ?? ''; copyText(t); onCopy?.(t); };

  return (
    <div data-dm="" className={cx('dm-rvx', !bare && 'dm-panel', className)} style={bare ? undefined : { padding: 16 }}>
      {prompt && <div className="dm-bub">{prompt}</div>}
      <div className="dm-rvx-a">
        <p className={cx('dm-msg dm-rvx-t', out && 'dm-out')} aria-live="polite">
          {words.map((w, k) => <span key={shown + ':' + k} className="dm-w" style={jump ? { animationDelay: k * 12 + 'ms' } : undefined}>{w} </span>)}
        </p>
        <div className="dm-rvx-bar">
          <div className="dm-rvx-pg">
            <Tip label="Previous"><button type="button" className="dm-ib" aria-label="Previous version" disabled={at === 0 || streaming} onClick={() => setCur(at - 1)}><Icon name="chev-l" /></button></Tip>
            <span className="dm-rvx-n dm-num">{at + 1} / {Math.max(versions.length, 1)}</span>
            <Tip label="Next"><button type="button" className="dm-ib" aria-label="Next version" disabled={at === last || streaming} onClick={() => setCur(at + 1)}><Icon name="chev-r" /></button></Tip>
          </div>
          {onRegenerate && <Tip label="Regenerate" kbd="⌘R"><button type="button" className={cx('dm-ib dm-rvx-re', spin && 'dm-go')} aria-label="Regenerate" onClick={regenerate}><Icon name="refresh" /></button></Tip>}
          <Tip label={copied ? 'Copied to clipboard' : 'Copy'} open={copied || undefined}><button type="button" className={cx('dm-ib', copied && 'dm-copied')} aria-label="Copy" onClick={copy}><Icon name={copied ? 'check' : 'copy'} /></button></Tip>
          <Swap className={cx('dm-rvx-st dm-muted', streaming && status && 'dm-shim')}>{status}</Swap>
        </div>
      </div>
    </div>
  );
}
response-versions.cssReact · 18 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Response versions ---------- */
.dm-rvx{display:flex;flex-direction:column;gap:14px}
.dm-rvx-a{display:flex;flex-direction:column;gap:var(--dm-s-2);padding:0 2px}
.dm-rvx-t{min-height:72px}
.dm-rvx-t .dm-w{animation:dm-rvw .45s var(--dm-ease) both}
@keyframes dm-rvw{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:none}}
.dm-rvx-t.dm-out{animation:dm-rvo .3s var(--dm-ease) both}
@keyframes dm-rvo{to{opacity:0;filter:blur(4px);transform:translateY(-2px)}}
.dm-rvx-bar{display:flex;align-items:center;gap:2px;margin-left:-6px}
.dm-rvx-pg{display:flex;align-items:center;gap:0}
.dm-rvx-pg .dm-ib{width:26px;height:26px}
.dm-rvx-pg .dm-ib[disabled]{opacity:1;cursor:not-allowed;background:none;color:var(--dm-dis)}
.dm-rvx-n{font-size:12px;color:var(--dm-muted);min-width:34px;text-align:center}
.dm-rvx-re svg{transition:transform .7s var(--dm-ease)}
.dm-rvx-re.dm-go svg{transform:rotate(360deg)}
.dm-rvx-st{font-size:12px;margin-left:6px}
response-versions.htmlHTML · 17 lines
<div class="card bare" data-c="response-versions">
  <div class="stage-in">
    <div class="rvx">
      <div class="bub">Write a headline for the pricing page</div>
      <div class="rvx-a">
        <p class="msg rvx-t"></p>
        <div class="rvx-bar">
          <div class="rvx-pg"><button class="ib rvx-prev" aria-label="Previous version" data-tip="Previous"><svg class="i s"><use href="#i-chev-l"/></svg></button><span class="rvx-n num">1 / 1</span><button class="ib rvx-next" aria-label="Next version" data-tip="Next"><svg class="i s"><use href="#i-chev-r"/></svg></button></div>
          <button class="ib rvx-re" aria-label="Regenerate" data-tip="Regenerate" data-tip-k="⌘R"><svg class="i s"><use href="#i-refresh"/></svg></button>
          <button class="ib rvx-cp" aria-label="Copy" data-tip="Copy"><svg class="i s"><use href="#i-copy"/></svg></button>
          <span class="rvx-st muted"></span>
        </div>
      </div>
    </div>
  </div>
</div>
response-versions.cssHTML · 17 lines
/* ---------- Response versions ---------- */
.rvx{display:flex;flex-direction:column;gap:14px}
.rvx-a{display:flex;flex-direction:column;gap:var(--s-2);padding:0 2px}
.rvx-t{min-height:72px}
.rvx-t .w{animation:rvw .45s var(--ease) both}
@keyframes rvw{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:none}}
.rvx-t.out{animation:rvo .3s var(--ease) both}
@keyframes rvo{to{opacity:0;filter:blur(4px);transform:translateY(-2px)}}
.rvx-bar{display:flex;align-items:center;gap:2px;margin-left:-6px}
.rvx-pg{display:flex;align-items:center;gap:0}
.rvx-pg .ib{width:26px;height:26px}
.rvx-pg .ib[disabled]{opacity:1;cursor:not-allowed;background:none;color:var(--dis)}
.rvx-n{font-size:12px;color:var(--muted);min-width:34px;text-align:center}
.rvx-re svg{transition:transform .7s var(--ease)}
.rvx-re.go svg{transform:rotate(360deg)}
.rvx-st{font-size:12px;margin-left:6px}
response-versions.jsHTML · 29 lines
DM.def('response-versions', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{l}', I('chev-l')).replace('{rr}', I('chev-r')).replace('{re}', I('refresh')).replace('{cp}', I('copy'));
  const pool = [
    'Pricing that grows with your team. Start free, upgrade when the work gets serious.',
    'One plan per stage of your team. No seat math, no surprises on the invoice.',
    'Design faster with an agent on every plan. Pay for what you ship, not for seats.',
    'Simple pricing for teams who ship. Free to start, fair as you grow.'
  ];
  const vers = [pool[0]]; let cur = 0, busy = false;
  const t = $('.rvx-t', r), n = $('.rvx-n', r), pv = $('.rvx-prev', r), nx = $('.rvx-next', r), re = $('.rvx-re', r), st = $('.rvx-st', r);
  const nav = () => { n.textContent = (cur + 1) + ' / ' + vers.length; pv.disabled = cur === 0 || busy; nx.disabled = cur === vers.length - 1 || busy; };
  async function stream(s){
    t.classList.add('out'); await c.wait(260); t.classList.remove('out'); t.innerHTML = '';
    for (const w of s.split(' ')){ t.insertAdjacentHTML('beforeend', `<span class="w">${w} </span>`); await c.wait(45); }
  }
  async function show(i){ cur = i; nav(); t.classList.add('out'); await c.wait(240); t.classList.remove('out'); t.innerHTML = vers[i].split(' ').map((w, k) => `<span class="w" style="animation-delay:${k * 12}ms">${w} </span>`).join(''); }
  pv.onclick = () => c.run(() => show(cur - 1));
  nx.onclick = () => c.run(() => show(cur + 1));
  re.onclick = () => { if (busy) return; busy = true; re.classList.add('go'); c.later(700, () => re.classList.remove('go'));
    c.run(async () => { swap(st, 'Writing a new version'); st.classList.add('shim');
      const s = pool[vers.length % pool.length]; vers.push(s); cur = vers.length - 1; nav();
      await stream(s); busy = false; nav(); st.classList.remove('shim'); swap(st, ''); }); };
  const cp = $('.rvx-cp', r);
  cp.onclick = () => copied(cp, vers[cur]);
  c.run(async () => { busy = true; nav(); await c.wait(300); await stream(vers[0]); busy = false; nav();
    // then one regenerate plays by itself, so the version count and arrows show what they do, unless you have taken over
    await c.step(1900); if (vers.length === 1) re.click(); });
});
06

Questions

What people ask about it.

How do I stream a regenerated answer into a new version?
In onRegenerate, append an empty string to versions, set version to its index and streaming to true, then update that string as tokens arrive. Set streaming back to false when it is done.
Why are the arrows greyed out?
They are disabled while streaming is true, and at the first or last version. That keeps people from paging away from an answer that is still being written.
Can the answer contain formatting like lists or code?
No. Each version is a plain string, split into words for the fade-in, so markup is shown as text.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
All 15 in Conversation →