Skip to content
AI Interfaces block

Chat Beside a Canvas

The two-pane assistant layout, with the mismatch nobody else states: the canvas names the version it is showing and says so when that is not the one the conversation is about. Versions link back to the turn that made them.

321 lineslucide-reactAdded 27 Aug 2026Updated 2 Sept 2026
  • canvas
  • artifact
  • versions
  • assistant
  • two-pane

What's included

  • components/chat-artifact-canvas.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add chat-artifact-canvas

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Thread

  • Build me a pricing card, three plans side by side.

  • Here it is — one heading, one price, one call to action per plan.

  • Add a monthly / yearly toggle.

  • Added. The heading says which period is showing rather than only the switch.

  • A screen reader reads the per-seat note as part of the price.

  • Moved it out of the price and linked the two with aria-describedby.

Ask for another change…

PriceCard.tsx

v2 · 14:06

You are looking at v2. The assistant's latest answer is about v3 — anything you ask now applies to that one.

export function PriceCard({ plan, annual }) {
  // price derived from the period rather than hard-coded
  // the heading names the period, so the switch is not
  // the only place the state is visible
}

Versions

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/chat-artifact-canvas.tsx
'use client'

/**
 * <ChatArtifactCanvas> — the conversation beside the thing it is making.
 *
 * Agent Chat had the thread, the composer, the streaming answer, the
 * starter screen and the version switcher: five surfaces about messages.
 * The layout every assistant that produces *work* has converged on — chat
 * on the left, the document or component it is building on the right —
 * was not among them.
 *
 * THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
 *
 * The two panes disagree, constantly, and almost nothing says so. Scroll
 * back to an earlier message and the canvas is still showing the latest
 * version; ask about "the header" and it is ambiguous which header. Here
 * the canvas names the version it is showing and, when that is not the
 * newest, says it plainly with one control to return — because a silent
 * mismatch between what you are reading and what you are pointing at is
 * how people ask the assistant to fix something it already fixed.
 *
 * A VERSION KNOWS WHICH MESSAGE MADE IT
 *
 * Each version carries the turn that produced it, so the history is a
 * record of decisions rather than a list of numbers. Selecting a message
 * moves the canvas; selecting a version highlights the message. One
 * relationship, navigable from both ends.
 *
 * EDITS DO NOT SILENTLY BRANCH
 *
 * The canvas is editable, and an edit on top of an old version would fork
 * the document invisibly. It is refused with a reason and a choice —
 * return to the newest, or branch on purpose — instead of being allowed
 * and quietly discarded on the next turn.
 *
 * ACCESSIBILITY: two labelled regions so a screen reader can move between
 * panes; the version list is a real list with `aria-current`; the
 * out-of-date warning is `role="status"`. On a narrow screen the panes
 * stack, with the canvas first — the answer matters more than the ask.
 */

import * as React from 'react'
import { ArrowDown, Code2, FileText, History, MessageSquare, Sparkles } from 'lucide-react'

export interface CanvasVersion {
  id: string
  label: string
  /** The user turn that produced this version. */
  fromMessage: string
  at: string
  /** Lines shown in the canvas. Kept as text — this is a demo, not an IDE. */
  body: string[]
}

export interface ChatTurn {
  id: string
  role: 'user' | 'assistant'
  text: string
  /** Version this turn produced, for assistant turns that changed the canvas. */
  producedVersion?: string
}

export interface ChatArtifactCanvasProps {
  turns?: ChatTurn[]
  versions?: CanvasVersion[]
  className?: string
}

/*
 * The sample deliberately contains no angle-bracket markup.
 *
 * `scripts/audit-a11y.mts` reads block sources as text, so a literal tag
 * inside a code sample is indistinguishable from one the block renders —
 * a demo showing an example table failed the table-headers rule for a
 * table that does not exist. Prose-and-signature form says the same thing
 * and keeps the audit reading only real markup.
 */
const DEFAULT_VERSIONS: CanvasVersion[] = [
  {
    id: 'v1',
    label: 'v1',
    fromMessage: 'm-1',
    at: '14:02',
    body: [
      'export function PriceCard({ plan }) {',
      '  // heading, price, feature list, one call to action',
      '  // three of these side by side, no period switch',
      '}',
    ],
  },
  {
    id: 'v2',
    label: 'v2',
    fromMessage: 'm-3',
    at: '14:06',
    body: [
      'export function PriceCard({ plan, annual }) {',
      '  // price derived from the period rather than hard-coded',
      '  // the heading names the period, so the switch is not',
      '  // the only place the state is visible',
      '}',
    ],
  },
  {
    id: 'v3',
    label: 'v3',
    fromMessage: 'm-5',
    at: '14:11',
    body: [
      'export function PriceCard({ plan, annual }) {',
      '  // per-seat note moved out of the price element, so it is',
      '  // no longer announced as part of the number',
      '  // aria-describedby links the two back together',
      '}',
    ],
  },
]

const DEFAULT_TURNS: ChatTurn[] = [
  { id: 'm-1', role: 'user', text: 'Build me a pricing card, three plans side by side.' },
  {
    id: 'm-2',
    role: 'assistant',
    text: 'Here it is — one heading, one price, one call to action per plan.',
    producedVersion: 'v1',
  },
  { id: 'm-3', role: 'user', text: 'Add a monthly / yearly toggle.' },
  {
    id: 'm-4',
    role: 'assistant',
    text: 'Added. The heading says which period is showing rather than only the switch.',
    producedVersion: 'v2',
  },
  { id: 'm-5', role: 'user', text: 'A screen reader reads the per-seat note as part of the price.' },
  {
    id: 'm-6',
    role: 'assistant',
    text: 'Moved it out of the price and linked the two with aria-describedby.',
    producedVersion: 'v3',
  },
]

export function ChatArtifactCanvas({
  turns = DEFAULT_TURNS,
  versions = DEFAULT_VERSIONS,
  className = '',
}: ChatArtifactCanvasProps) {
  const newest = versions[versions.length - 1]
  /*
   * Opens on the second-newest version, not the newest.
   *
   * On the newest, the two panes agree and the component looks like any
   * other split layout. The out-of-date banner — the one thing here that
   * most implementations leave silent — only exists when they disagree,
   * so the demo starts where the disagreement is. "Go to v3" resolves it.
   */
  const [shownId, setShownId] = React.useState(
    versions[versions.length - 2]?.id ?? newest?.id ?? '',
  )
  const [editAttempt, setEditAttempt] = React.useState(false)

  const shown = versions.find((v) => v.id === shownId) ?? newest
  const stale = Boolean(newest && shown && shown.id !== newest.id)

  if (!shown || !newest) return null

  function show(id: string) {
    setShownId(id)
    setEditAttempt(false)
  }

  return (
    <section className={`mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 ${className}`}>
      <div className="overflow-y-hidden rounded-2xl border border-border bg-card">
        {/* Canvas first in the source order: on a phone the artifact is
            what the visitor came for, and the thread is the context. */}
        <div className="grid lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
          <section
            aria-label="Conversation"
            className="order-2 flex flex-col border-border lg:order-1 lg:border-e"
          >
            <header className="flex items-center gap-2 border-y border-border px-4 py-2.5 lg:border-t-0">
              <MessageSquare aria-hidden className="h-4 w-4 text-muted-foreground" />
              <h2 className="text-sm font-semibold text-foreground">Thread</h2>
            </header>

            <ul className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
              {turns.map((turn) => {
                const active = turn.producedVersion === shown.id
                return (
                  <li key={turn.id} className={turn.role === 'user' ? 'text-end' : ''}>
                    <div
                      className={`inline-block max-w-[85%] rounded-2xl px-3 py-2 text-start text-sm ${
                        turn.role === 'user'
                          ? 'bg-primary text-primary-foreground'
                          : `border bg-background ${
                              active ? 'border-primary/50' : 'border-border'
                            }`
                      }`}
                    >
                      <p className="leading-relaxed">{turn.text}</p>
                      {turn.producedVersion ? (
                        /* Navigable from the message end of the relationship. */
                        <button
                          type="button"
                          onClick={() => show(turn.producedVersion!)}
                          className="mt-1.5 inline-flex items-center gap-1 rounded text-xs font-medium text-primary underline-offset-2 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          <Sparkles aria-hidden className="h-3 w-3" />
                          {active ? `Showing ${turn.producedVersion}` : `Show ${turn.producedVersion}`}
                        </button>
                      ) : null}
                    </div>
                  </li>
                )
              })}
            </ul>

            <div className="border-t border-border p-3">
              <div className="flex h-10 items-center rounded-xl border border-field bg-background px-3 text-sm text-muted-foreground">
                Ask for another change…
              </div>
            </div>
          </section>

          <section aria-label="Canvas" className="order-1 flex flex-col lg:order-2">
            <header className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
              <Code2 aria-hidden className="h-4 w-4 text-muted-foreground" />
              <h2 className="text-sm font-semibold text-foreground">PriceCard.tsx</h2>
              {/* The version this pane is showing, always named. */}
              <span className="rounded-md bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
                {shown.label} · {shown.at}
              </span>
              <button
                type="button"
                onClick={() => setEditAttempt(true)}
                className="ms-auto inline-flex h-7 items-center gap-1 rounded-lg border border-border bg-background px-2 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <FileText aria-hidden className="h-3.5 w-3.5" />
                Edit
              </button>
            </header>

            {/*
              The mismatch, said out loud. Everything above this line is
              ordinary; this is the part most implementations leave silent.
            */}
            {stale ? (
              <div
                role="status"
                className="flex flex-wrap items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-2.5"
              >
                <p className="min-w-0 flex-1 text-xs text-amber-800 dark:text-amber-300">
                  You are looking at {shown.label}. The assistant&apos;s latest answer is
                  about {newest.label} — anything you ask now applies to that one.
                </p>
                <button
                  type="button"
                  onClick={() => show(newest.id)}
                  className="inline-flex h-7 items-center gap-1 rounded-lg bg-foreground px-2.5 text-xs font-semibold text-background transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <ArrowDown aria-hidden className="h-3.5 w-3.5" />
                  Go to {newest.label}
                </button>
              </div>
            ) : null}

            {editAttempt && stale ? (
              <div className="border-b border-border bg-muted/50 px-4 py-2.5 text-xs text-muted-foreground">
                Editing {shown.label} would create a branch, because {newest.label} already
                exists.{' '}
                <button
                  type="button"
                  onClick={() => show(newest.id)}
                  className="rounded font-medium text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  Edit {newest.label} instead
                </button>
                , or branch from here on purpose.
              </div>
            ) : null}

            <pre className="min-h-0 flex-1 overflow-auto bg-background/60 p-4 font-mono text-xs leading-relaxed text-foreground">
              <code>{shown.body.join('\n')}</code>
            </pre>

            <div className="border-t border-border px-4 py-2.5">
              <h3 className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
                <History aria-hidden className="h-3.5 w-3.5" />
                Versions
              </h3>
              <ul className="mt-1.5 flex flex-wrap gap-1.5">
                {versions.map((version) => {
                  const current = version.id === shown.id
                  return (
                    <li key={version.id}>
                      <button
                        type="button"
                        aria-current={current ? 'true' : undefined}
                        onClick={() => show(version.id)}
                        className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
                          current
                            ? 'border-primary bg-primary/10 font-semibold text-foreground'
                            : 'border-border bg-background text-muted-foreground hover:text-foreground'
                        }`}
                      >
                        {version.label}
                        <span className="tabular-nums opacity-70">{version.at}</span>
                      </button>
                    </li>
                  )
                })}
              </ul>
            </div>
          </section>
        </div>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/chat-artifact-canvas.tsx and import it where you need the section:

import { PriceCard } from '@/components/chat-artifact-canvas'

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
turnsChatTurn[]—
versionsCanvasVersion[]—
classNamestring—

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

chat-artifact-canvas.html
<!--
  Chat Beside a Canvas — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 ">
  <div class="overflow-y-hidden rounded-2xl border border-border bg-card">
    <div class="grid lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
      <section aria-label="Conversation" class="order-2 flex flex-col border-border lg:order-1 lg:border-e">
        <header class="flex items-center gap-2 border-y border-border px-4 py-2.5 lg:border-t-0">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-square h-4 w-4 text-muted-foreground" aria-hidden="true">
            <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
          </svg>
          <h2 class="text-sm font-semibold text-foreground">Thread</h2>
        </header>
        <ul class="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
          <li class="text-end">
            <div class="inline-block max-w-[85%] rounded-2xl px-3 py-2 text-start text-sm bg-primary text-primary-foreground">
              <p class="leading-relaxed">Build me a pricing card, three plans side by side.</p>
            </div>
          </li>
          <li class="">
            <div class="inline-block max-w-[85%] rounded-2xl px-3 py-2 text-start text-sm border bg-background border-border">
              <p class="leading-relaxed">Here it is — one heading, one price, one call to action per plan.</p>
              <button type="button" class="mt-1.5 inline-flex items-center gap-1 rounded text-xs font-medium text-primary underline-offset-2 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles h-3 w-3" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"></path><path d="M20 3v4"></path><path d="M22 5h-4"></path><path d="M4 17v2"></path><path d="M5 18H3"></path></svg>Show v1</button>
            </div>
          </li>
          <li class="text-end">
            <div class="inline-block max-w-[85%] rounded-2xl px-3 py-2 text-start text-sm bg-primary text-primary-foreground">
              <p class="leading-relaxed">Add a monthly / yearly toggle.</p>
            </div>
          </li>
          <li class="">
            <div class="inline-block max-w-[85%] rounded-2xl px-3 py-2 text-start text-sm border bg-background border-primary/50">
              <p class="leading-relaxed">Added. The heading says which period is showing rather than only the switch.</p>
              <button type="button" class="mt-1.5 inline-flex items-center gap-1 rounded text-xs font-medium text-primary underline-offset-2 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles h-3 w-3" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"></path><path d="M20 3v4"></path><path d="M22 5h-4"></path><path d="M4 17v2"></path><path d="M5 18H3"></path></svg>Showing v2</button>
            </div>
          </li>
          <li class="text-end">
            <div class="inline-block max-w-[85%] rounded-2xl px-3 py-2 text-start text-sm bg-primary text-primary-foreground">
              <p class="leading-relaxed">A screen reader reads the per-seat note as part of the price.</p>
            </div>
          </li>
          <li class="">
            <div class="inline-block max-w-[85%] rounded-2xl px-3 py-2 text-start text-sm border bg-background border-border">
              <p class="leading-relaxed">Moved it out of the price and linked the two with aria-describedby.</p>
              <button type="button" class="mt-1.5 inline-flex items-center gap-1 rounded text-xs font-medium text-primary underline-offset-2 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles h-3 w-3" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"></path><path d="M20 3v4"></path><path d="M22 5h-4"></path><path d="M4 17v2"></path><path d="M5 18H3"></path></svg>Show v3</button>
            </div>
          </li>
        </ul>
        <div class="border-t border-border p-3">
          <div class="flex h-10 items-center rounded-xl border border-field bg-background px-3 text-sm text-muted-foreground">Ask for another change…</div>
        </div>
      </section>
      <section aria-label="Canvas" class="order-1 flex flex-col lg:order-2">
        <header class="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-xml h-4 w-4 text-muted-foreground" aria-hidden="true">
            <path d="m18 16 4-4-4-4"></path>
            <path d="m6 8-4 4 4 4"></path>
            <path d="m14.5 4-5 16"></path>
          </svg>
          <h2 class="text-sm font-semibold text-foreground">PriceCard.tsx</h2>
          <span class="rounded-md bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">v2 · 14:06</span>
          <button type="button" class="ms-auto inline-flex h-7 items-center gap-1 rounded-lg border border-border bg-background px-2 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text h-3.5 w-3.5" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>Edit</button>
        </header>
        <div role="status" class="flex flex-wrap items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-2.5">
          <p class="min-w-0 flex-1 text-xs text-amber-800 dark:text-amber-300">You are looking at v2. The assistant&#x27;s latest answer is about v3 — anything you ask now applies to that one.</p>
          <button type="button" class="inline-flex h-7 items-center gap-1 rounded-lg bg-foreground px-2.5 text-xs font-semibold text-background transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down h-3.5 w-3.5" aria-hidden="true"><path d="M12 5v14"></path><path d="m19 12-7 7-7-7"></path></svg>Go to v3</button>
        </div>
        <pre class="min-h-0 flex-1 overflow-auto bg-background/60 p-4 font-mono text-xs leading-relaxed text-foreground"><code>export function PriceCard({ plan, annual }) {
  // price derived from the period rather than hard-coded
  // the heading names the period, so the switch is not
  // the only place the state is visible
}</code></pre>
        <div class="border-t border-border px-4 py-2.5">
          <h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-history h-3.5 w-3.5" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>Versions</h3>
          <ul class="mt-1.5 flex flex-wrap gap-1.5">
            <li>
              <button type="button" class="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border bg-background text-muted-foreground hover:text-foreground">v1<span class="tabular-nums opacity-70">14:02</span></button>
            </li>
            <li>
              <button type="button" aria-current="true" class="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-primary bg-primary/10 font-semibold text-foreground">v2<span class="tabular-nums opacity-70">14:06</span></button>
            </li>
            <li>
              <button type="button" class="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border bg-background text-muted-foreground hover:text-foreground">v3<span class="tabular-nums opacity-70">14:11</span></button>
            </li>
          </ul>
        </div>
      </section>
    </div>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Agent Chat

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Agent Chat

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Agent Chat blocks

View category
Open the full page for this block

Assistant Thread with Composer

A scrolling conversation with per-turn actions, announced as a polite log restricted to additions — so a streaming reply is not re-read on every token — and auto-scroll that only pins when the user is already at the bottom.

Agent Chat253 lines1 dep
Open the full page for this block

Composer with @ Sources and / Commands

The trigger menus done properly: @ and / open only at a token boundary, the query is derived from the caret so the menu closes itself, and focus never leaves the textarea — the combobox pattern, not a floating div.

Agent Chat434 lines1 dep
Open the full page for this block

Streaming Answer with Citations

Token-by-token text with linked citations, follow-up chips and a caret that stops the moment the stream does. The status is announced once; the prose is deliberately outside every live region.

Agent Chat296 lines1 dep
Open the full page for this block

Assistant Starter Screen

The blank thread, answered: grouped starter prompts as real lists of buttons, each group labelled, so the highest-leverage screen in an AI product is not a centred logo.

Agent Chat138 lines1 dep
Open the full page for this block

Message Version Switcher

The answers a regenerate throws away — siblings rather than replacements, an edited question that branches in place, and a plain sentence about what happens to the replies below.

Agent Chat272 lines1 dep
Open the full page for this block

Model Picker with Trade-offs

Model selection with the three numbers the choice actually turns on — capability, latency and cost per message — and locked models explained rather than greyed out.

Agent Chat267 lines1 dep
Open the full page for this block

Prompt Attachment Tray

Files staged against a prompt with a running context-token budget, unreadable files flagged before send, removal allowed mid-upload, and a send button that names its one blocker.

Agent Chat269 lines1 dep