Skip to content
AI Interfaces 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.

267 lineslucide-reactAdded 31 Aug 2026Updated 11 Sept 2026
  • ai
  • model picker
  • chat
  • listbox
  • cost

What's included

  • components/chat-model-picker.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add chat-model-picker

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

Changing model applies to your next message. Earlier replies in this thread stay as the model that wrote them left them.

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

Source

components/chat-model-picker.tsx
'use client'

/**
 * <ChatModelPicker> — choosing a model with the trade-off on screen.
 *
 * Agent Chat has the thread, the prompt bar, the streaming answer, branches
 * and a canvas. All of them assume the model has already been chosen. Every
 * product that ships more than one model needs this control, and almost all
 * of them ship it as a dropdown of names — which asks the user to know from
 * memory which of four names is the cheap fast one.
 *
 * THE THREE NUMBERS THAT MAKE THE CHOICE
 *
 * Relative capability, typical latency, and cost per message. Those are the
 * axes anyone actually trades between, and a picker that omits them is
 * asking for a decision while withholding the inputs. Cost is shown per
 * message rather than per million tokens, because per-token pricing is a
 * unit nobody can convert in their head at the moment of choosing.
 *
 * THE UNAVAILABLE STATE IS EXPLAINED, NOT GREYED
 *
 * A model the current plan cannot reach renders with the reason and the
 * upgrade path attached. A disabled row with no explanation is the most
 * common way this control turns into a support ticket.
 *
 * SWITCHING MID-THREAD IS CALLED OUT. Changing model does not re-run the
 * conversation, and the earlier turns stay as the previous model wrote
 * them. Saying so in the panel is cheaper than the confusion it prevents.
 *
 * ACCESSIBILITY: a listbox with roving focus, `aria-activedescendant`
 * avoided in favour of real focusable options — simpler and far more
 * reliably announced. The trigger reports the current selection in its
 * accessible name; Escape closes and returns focus.
 */

import * as React from 'react'
import { Check, ChevronDown, Gauge, Lock, Zap } from 'lucide-react'

export interface ChatModel {
  id: string
  name: string
  vendor: string
  blurb: string
  /** 1–5, relative within this list rather than an absolute claim. */
  capability: number
  latency: string
  costPerMessage: string
  /** Absent means available on the current plan. */
  lockedReason?: string
}

export interface ChatModelPickerProps {
  models?: ChatModel[]
  initialModelId?: string
  className?: string
}

const DEFAULT_MODELS: ChatModel[] = [
  {
    id: 'fast',
    name: 'Swift',
    vendor: 'In-house',
    blurb: 'Drafts, summaries and anything you will read and rewrite anyway.',
    capability: 2,
    latency: '~0.4s',
    costPerMessage: '1 credit',
  },
  {
    id: 'balanced',
    name: 'Standard',
    vendor: 'In-house',
    blurb: 'The default. Handles most reasoning without thinking about it.',
    capability: 4,
    latency: '~1.2s',
    costPerMessage: '4 credits',
  },
  {
    id: 'deep',
    name: 'Deliberate',
    vendor: 'In-house',
    blurb: 'Long chains of reasoning, code review, anything you will act on unread.',
    capability: 5,
    latency: '~6s',
    costPerMessage: '18 credits',
  },
  {
    id: 'vision',
    name: 'Standard Vision',
    vendor: 'In-house',
    blurb: 'Reads screenshots, diagrams and PDFs alongside the prompt.',
    capability: 4,
    latency: '~2s',
    costPerMessage: '6 credits',
    lockedReason: 'Included with Pro+ — your plan does not have image input.',
  },
]

function CapabilityBar({ level }: { level: number }) {
  return (
    <span aria-hidden className="flex items-center gap-0.5">
      {[1, 2, 3, 4, 5].map((step) => (
        <span
          key={step}
          className={`h-1 w-3 rounded-full ${step <= level ? 'bg-primary' : 'bg-muted-foreground/25'}`}
        />
      ))}
    </span>
  )
}

export function ChatModelPicker({
  models = DEFAULT_MODELS,
  initialModelId = 'balanced',
  className = '',
}: ChatModelPickerProps) {
  /*
    Per-instance prefix for every id this block emits.

    The literals these replaced were a latent duplicate the moment the
    block appeared twice on one document, and `aria-labelledby` on a
    duplicated id resolves to the first match -- so the second copy was
    labelled by the first copy's heading. Client component, so `useId` is
    the right tool.
  */
  const uid = React.useId()

  const [open, setOpen] = React.useState(true)
  const [selectedId, setSelectedId] = React.useState(initialModelId)
  const triggerRef = React.useRef<HTMLButtonElement>(null)

  const selected = models.find((model) => model.id === selectedId) ?? models[0]

  function choose(model: ChatModel) {
    if (model.lockedReason) return
    setSelectedId(model.id)
    setOpen(false)
    triggerRef.current?.focus()
  }

  return (
    /*
      The demo opens on the panel, because the panel is what this block is
      about, and the panel is `absolute` -- it adds nothing to the height of
      the section it hangs from. Any `overflow-hidden` ancestor (the preview
      frame, a card, a Figma trace) therefore clips it away and leaves a lone
      trigger. The outer wrapper reserves the room instead: 29rem is the
      panel's real height (max-h-96 list + footnote + the mt-2 gap).

      It is an OUTER wrapper on purpose. Padding on the `relative` element
      below would move `top-full` down with it and the panel would open a
      screen away from its trigger. In your own layout the panel can overlay
      whatever follows, so delete `pb-[29rem]` there.
    */
    <div className={`w-full max-w-md pb-[29rem] ${className}`}>
      <div
        className="relative"
        onKeyDown={(event) => {
          if (event.key === 'Escape' && open) {
            setOpen(false)
            triggerRef.current?.focus()
          }
        }}
      >
        <button
          ref={triggerRef}
          type="button"
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-controls={`${uid}-model-listbox`}
          onClick={() => setOpen((value) => !value)}
          className="flex w-full items-center justify-between gap-3 rounded-xl border border-border bg-card px-3 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          <span className="min-w-0">
            <span className="block text-sm font-semibold">{selected.name}</span>
            <span className="block text-xs text-muted-foreground">
              {selected.latency} · {selected.costPerMessage}
            </span>
          </span>
          <span className="sr-only">Current model: {selected.name}. Change model.</span>
          <ChevronDown
            aria-hidden
            className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
          />
        </button>

        {open ? (
          <div
            id={`${uid}-model-listbox`}
            role="listbox"
            aria-label="Model"
            className="absolute inset-x-0 top-full z-10 mt-2 overflow-hidden rounded-xl border border-border bg-card shadow-xl"
          >
            <ul className="max-h-96 overflow-y-auto">
              {models.map((model) => {
                const isSelected = model.id === selectedId
                const locked = Boolean(model.lockedReason)

                return (
                  <li key={model.id}>
                    <button
                      type="button"
                      role="option"
                      aria-selected={isSelected}
                      aria-disabled={locked || undefined}
                      onClick={() => choose(model)}
                      className={`flex w-full flex-col items-start gap-2 border-b border-border/60 p-3 text-start transition-colors last:border-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
                        locked
                          ? 'cursor-not-allowed opacity-70'
                          : isSelected
                            ? 'bg-primary/5'
                            : 'hover:bg-muted/50'
                      }`}
                    >
                      <span className="flex w-full items-center gap-2">
                        <span className="text-sm font-semibold">{model.name}</span>
                        <span className="text-xs text-muted-foreground">{model.vendor}</span>
                        {locked ? (
                          <Lock aria-hidden className="h-3.5 w-3.5 text-muted-foreground" />
                        ) : null}
                        {isSelected ? (
                          <Check aria-hidden className="ms-auto h-4 w-4 text-primary" />
                        ) : null}
                      </span>

                      <span className="text-xs text-muted-foreground">{model.blurb}</span>

                      {/* The three numbers. Announced as words, not bars. */}
                      <span className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
                        <span className="inline-flex items-center gap-1.5">
                          <CapabilityBar level={model.capability} />
                          <span className="sr-only">
                            Capability {model.capability} of 5.
                          </span>
                        </span>
                        <span className="inline-flex items-center gap-1">
                          <Gauge aria-hidden className="h-3 w-3" />
                          {model.latency}
                        </span>
                        <span className="inline-flex items-center gap-1">
                          <Zap aria-hidden className="h-3 w-3" />
                          {model.costPerMessage}
                        </span>
                      </span>

                      {/* Explained rather than greyed — see the header. */}
                      {locked ? (
                        <span className="mt-0.5 w-full rounded-lg bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">
                          {model.lockedReason}
                        </span>
                      ) : null}
                    </button>
                  </li>
                )
              })}
            </ul>

            <p className="border-t border-border bg-muted/40 p-3 text-[11px] text-muted-foreground">
              Changing model applies to your next message. Earlier replies in this thread
              stay as the model that wrote them left them.
            </p>
          </div>
        ) : null}
      </div>
    </div>
  )
}

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-model-picker.tsx and import it where you need the section:

import { ChatModelPicker } from '@/components/chat-model-picker'

Customize

1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

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
modelsChatModel[]DEFAULT_MODELS
initialModelIdstring'balanced'
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-model-picker.html
<!--
  Model Picker with Trade-offs — 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.
-->
<div class="w-full max-w-md pb-[29rem] ">
  <div class="relative">
    <button type="button" aria-haspopup="listbox" aria-expanded="true" aria-controls="_R_0_-model-listbox" class="flex w-full items-center justify-between gap-3 rounded-xl border border-border bg-card px-3 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
      <span class="min-w-0">
        <span class="block text-sm font-semibold">Standard</span>
        <span class="block text-xs text-muted-foreground">~1.2s · 4 credits</span>
      </span>
      <span class="sr-only">Current model: Standard. Change model.</span>
      <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-chevron-down h-4 w-4 shrink-0 text-muted-foreground transition-transform rotate-180" aria-hidden="true">
        <path d="m6 9 6 6 6-6"></path>
      </svg>
    </button>
    <div id="_R_0_-model-listbox" role="listbox" aria-label="Model" class="absolute inset-x-0 top-full z-10 mt-2 overflow-hidden rounded-xl border border-border bg-card shadow-xl">
      <ul class="max-h-96 overflow-y-auto">
        <li>
          <button type="button" role="option" aria-selected="false" class="flex w-full flex-col items-start gap-2 border-b border-border/60 p-3 text-start transition-colors last:border-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring hover:bg-muted/50">
            <span class="flex w-full items-center gap-2">
              <span class="text-sm font-semibold">Swift</span>
              <span class="text-xs text-muted-foreground">In-house</span>
            </span>
            <span class="text-xs text-muted-foreground">Drafts, summaries and anything you will read and rewrite anyway.</span>
            <span class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
              <span class="inline-flex items-center gap-1.5">
                <span aria-hidden="true" class="flex items-center gap-0.5">
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-muted-foreground/25"></span>
                  <span class="h-1 w-3 rounded-full bg-muted-foreground/25"></span>
                  <span class="h-1 w-3 rounded-full bg-muted-foreground/25"></span>
                </span>
                <span class="sr-only">Capability 2 of 5.</span>
              </span>
              <span class="inline-flex items-center gap-1"><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-gauge h-3 w-3" aria-hidden="true"><path d="m12 14 4-4"></path><path d="M3.34 19a10 10 0 1 1 17.32 0"></path></svg>~0.4s</span>
              <span class="inline-flex items-center gap-1"><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-zap h-3 w-3" aria-hidden="true"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg>1 credit</span>
            </span>
          </button>
        </li>
        <li>
          <button type="button" role="option" aria-selected="true" class="flex w-full flex-col items-start gap-2 border-b border-border/60 p-3 text-start transition-colors last:border-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring bg-primary/5">
            <span class="flex w-full items-center gap-2">
              <span class="text-sm font-semibold">Standard</span>
              <span class="text-xs text-muted-foreground">In-house</span>
              <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-check ms-auto h-4 w-4 text-primary" aria-hidden="true">
                <path d="M20 6 9 17l-5-5"></path>
              </svg>
            </span>
            <span class="text-xs text-muted-foreground">The default. Handles most reasoning without thinking about it.</span>
            <span class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
              <span class="inline-flex items-center gap-1.5">
                <span aria-hidden="true" class="flex items-center gap-0.5">
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-muted-foreground/25"></span>
                </span>
                <span class="sr-only">Capability 4 of 5.</span>
              </span>
              <span class="inline-flex items-center gap-1"><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-gauge h-3 w-3" aria-hidden="true"><path d="m12 14 4-4"></path><path d="M3.34 19a10 10 0 1 1 17.32 0"></path></svg>~1.2s</span>
              <span class="inline-flex items-center gap-1"><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-zap h-3 w-3" aria-hidden="true"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg>4 credits</span>
            </span>
          </button>
        </li>
        <li>
          <button type="button" role="option" aria-selected="false" class="flex w-full flex-col items-start gap-2 border-b border-border/60 p-3 text-start transition-colors last:border-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring hover:bg-muted/50">
            <span class="flex w-full items-center gap-2">
              <span class="text-sm font-semibold">Deliberate</span>
              <span class="text-xs text-muted-foreground">In-house</span>
            </span>
            <span class="text-xs text-muted-foreground">Long chains of reasoning, code review, anything you will act on unread.</span>
            <span class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
              <span class="inline-flex items-center gap-1.5">
                <span aria-hidden="true" class="flex items-center gap-0.5">
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                </span>
                <span class="sr-only">Capability 5 of 5.</span>
              </span>
              <span class="inline-flex items-center gap-1"><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-gauge h-3 w-3" aria-hidden="true"><path d="m12 14 4-4"></path><path d="M3.34 19a10 10 0 1 1 17.32 0"></path></svg>~6s</span>
              <span class="inline-flex items-center gap-1"><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-zap h-3 w-3" aria-hidden="true"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg>18 credits</span>
            </span>
          </button>
        </li>
        <li>
          <button type="button" role="option" aria-selected="false" aria-disabled="true" class="flex w-full flex-col items-start gap-2 border-b border-border/60 p-3 text-start transition-colors last:border-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring cursor-not-allowed opacity-70">
            <span class="flex w-full items-center gap-2">
              <span class="text-sm font-semibold">Standard Vision</span>
              <span class="text-xs text-muted-foreground">In-house</span>
              <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-lock h-3.5 w-3.5 text-muted-foreground" aria-hidden="true">
                <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
                <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
              </svg>
            </span>
            <span class="text-xs text-muted-foreground">Reads screenshots, diagrams and PDFs alongside the prompt.</span>
            <span class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
              <span class="inline-flex items-center gap-1.5">
                <span aria-hidden="true" class="flex items-center gap-0.5">
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-primary"></span>
                  <span class="h-1 w-3 rounded-full bg-muted-foreground/25"></span>
                </span>
                <span class="sr-only">Capability 4 of 5.</span>
              </span>
              <span class="inline-flex items-center gap-1"><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-gauge h-3 w-3" aria-hidden="true"><path d="m12 14 4-4"></path><path d="M3.34 19a10 10 0 1 1 17.32 0"></path></svg>~2s</span>
              <span class="inline-flex items-center gap-1"><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-zap h-3 w-3" aria-hidden="true"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg>6 credits</span>
            </span>
            <span class="mt-0.5 w-full rounded-lg bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">Included with Pro+ — your plan does not have image input.</span>
          </button>
        </li>
      </ul>
      <p class="border-t border-border bg-muted/40 p-3 text-[11px] text-muted-foreground">Changing model applies to your next message. Earlier replies in this thread stay as the model that wrote them left them.</p>
    </div>
  </div>
</div>
  • 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

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.

Agent Chat321 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