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

434 lineslucide-reactAdded 13 Aug 2026Updated 11 Sept 2026
  • composer
  • prompt
  • mentions
  • slash commands
  • combobox

What's included

  • components/chat-prompt-bar.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add chat-prompt-bar

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

  • Q3-forecast.xlsx

Enter to send · Shift↵ for a new line

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

Source

components/chat-prompt-bar.tsx
/**
 * <ChatPromptBar> — the composer, with `@` sources, `/` commands, a model
 * picker and attachment chips.
 *
 * This is the block people underestimate. A textarea and a send button take
 * five minutes; the trigger menus take a day, and this is what they need:
 *
 *  - The menu opens only when the trigger character starts a *token* — after
 *    a space or at position zero. Without that check, typing an email
 *    address opens the mention menu halfway through `you@example.com`, and
 *    every `and/or` opens the command menu.
 *  - The query is the text between the trigger and the caret, so the menu
 *    closes by itself when the user types past a match or backspaces over
 *    the trigger. No separate "is the menu open" state to get out of sync.
 *  - Focus never leaves the textarea. The menu is a `listbox` addressed by
 *    `aria-activedescendant`, the combobox pattern — moving real focus into
 *    the list would stop the user typing to filter it.
 *  - Arrow keys, Enter and Tab are intercepted only while the menu is open,
 *    and Escape closes the menu rather than clearing the draft.
 *  - Enter sends, Shift-Enter breaks the line, and `isComposing` is checked
 *    so committing an IME candidate does not send a half-typed message.
 *
 * The textarea grows with its content by resetting `height` to `auto` before
 * reading `scrollHeight` — skip the reset and it only ever grows, never
 * shrinks back when the draft is deleted.
 */

'use client'

import * as React from 'react'
import {
  ArrowUp,
  AtSign,
  ChevronDown,
  Database,
  FileText,
  Globe,
  Mic,
  Paperclip,
  Slash,
  Sparkles,
  X,
} from 'lucide-react'

export interface PromptSource {
  id: string
  label: string
  hint?: string
  kind?: 'file' | 'table' | 'web'
}

export interface PromptCommand {
  id: string
  label: string
  hint?: string
}

export interface ChatPromptBarProps {
  placeholder?: string
  sources?: PromptSource[]
  commands?: PromptCommand[]
  models?: string[]
  /** Chips shown above the field, as if already attached. */
  attachments?: string[]
  className?: string
}

const DEFAULT_SOURCES: PromptSource[] = [
  { id: 'q3-report', label: 'Q3-forecast.xlsx', hint: 'Sheet · 3 tabs', kind: 'file' },
  { id: 'orders', label: 'warehouse.orders', hint: 'Table · 2.1M rows', kind: 'table' },
  { id: 'accounts', label: 'warehouse.accounts', hint: 'Table · 48k rows', kind: 'table' },
  { id: 'handbook', label: 'Pricing handbook', hint: 'Doc · updated Tuesday', kind: 'file' },
  { id: 'status', label: 'status.acme.com', hint: 'Web · live', kind: 'web' },
]

const DEFAULT_COMMANDS: PromptCommand[] = [
  { id: 'summarise', label: '/summarise', hint: 'Condense the attached sources' },
  { id: 'chart', label: '/chart', hint: 'Plot the last result' },
  { id: 'compare', label: '/compare', hint: 'Diff two periods' },
  { id: 'explain', label: '/explain', hint: 'Walk through the reasoning' },
]

const DEFAULT_MODELS = ['Fast', 'Balanced', 'Deep reasoning']

const KIND_ICON = {
  file: FileText,
  table: Database,
  web: Globe,
} as const

export function ChatPromptBar({
  placeholder = 'Ask anything — @ for sources, / for commands',
  sources = DEFAULT_SOURCES,
  commands = DEFAULT_COMMANDS,
  models = DEFAULT_MODELS,
  attachments = ['Q3-forecast.xlsx'],
  className = '',
}: ChatPromptBarProps) {
  const [draft, setDraft] = React.useState('')
  const [caret, setCaret] = React.useState(0)
  const [highlight, setHighlight] = React.useState(0)
  /** Trigger position the user pressed Escape on, so it stays closed. */
  const [dismissed, setDismissed] = React.useState<number | null>(null)
  const [model, setModel] = React.useState(models[1] ?? models[0] ?? '')
  const [modelOpen, setModelOpen] = React.useState(false)
  const [chips, setChips] = React.useState(attachments)
  const [dictating, setDictating] = React.useState(false)

  const fieldRef = React.useRef<HTMLTextAreaElement>(null)

  /**
   * The active trigger, derived from the draft and caret rather than stored.
   *
   * Deriving it is what makes the menu close on its own: backspace over the
   * `@` and there is no trigger to find, so there is no state left behind
   * claiming the menu is open.
   */
  const trigger = React.useMemo(() => {
    const before = draft.slice(0, caret)
    const match = /(^|\s)([@/])([^\s@/]*)$/.exec(before)
    if (!match) return null
    return { char: match[2] as '@' | '/', query: match[3], start: caret - match[3].length - 1 }
  }, [draft, caret])

  const results = React.useMemo(() => {
    if (!trigger) return []
    const term = trigger.query.toLowerCase()
    const pool =
      trigger.char === '@'
        ? sources.map((s) => ({ id: s.id, label: s.label, hint: s.hint, kind: s.kind }))
        : commands.map((c) => ({ id: c.id, label: c.label, hint: c.hint, kind: undefined }))
    return pool.filter((item) => item.label.toLowerCase().includes(term))
  }, [trigger, sources, commands])

  const open = trigger !== null && results.length > 0 && trigger.start !== dismissed

  // A highlight left at index 4 after the list filters down to two selects
  // the wrong row on Enter.
  React.useEffect(() => {
    setHighlight(0)
  }, [trigger?.char, trigger?.query])

  function grow(el: HTMLTextAreaElement) {
    el.style.height = 'auto'
    el.style.height = `${Math.min(el.scrollHeight, 160)}px`
  }

  function accept(index: number) {
    const item = results[index]
    if (!item || !trigger) return

    if (trigger.char === '@') {
      // Sources become chips, not text — the model gets a reference and the
      // user gets something they can remove without editing their sentence.
      setChips((list) => (list.includes(item.label) ? list : [...list, item.label]))
    }

    const inserted = trigger.char === '@' ? '' : `${item.label} `
    const next = draft.slice(0, trigger.start) + inserted + draft.slice(caret)
    setDraft(next)

    const position = trigger.start + inserted.length
    setCaret(position)
    requestAnimationFrame(() => {
      const el = fieldRef.current
      if (!el) return
      el.focus()
      el.setSelectionRange(position, position)
      grow(el)
    })
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (open) {
      if (event.key === 'ArrowDown') {
        event.preventDefault()
        setHighlight((i) => (i + 1) % results.length)
        return
      }
      if (event.key === 'ArrowUp') {
        event.preventDefault()
        setHighlight((i) => (i - 1 + results.length) % results.length)
        return
      }
      if (event.key === 'Enter' || event.key === 'Tab') {
        event.preventDefault()
        accept(highlight)
        return
      }
      if (event.key === 'Escape') {
        // Dismiss this one trigger. Escape must never clear the draft, and
        // it must not simply toggle a flag either — starting a *new* `@`
        // later has to reopen the menu without any extra bookkeeping, which
        // recording the dismissed position gives for free.
        event.preventDefault()
        setDismissed(trigger.start)
        return
      }
    }

    if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
      event.preventDefault()
      setDraft('')
      requestAnimationFrame(() => {
        if (fieldRef.current) grow(fieldRef.current)
      })
    }
  }

  /** Keep the derived trigger in step with every caret move, not just typing. */
  function syncCaret(event: React.SyntheticEvent<HTMLTextAreaElement>) {
    setCaret(event.currentTarget.selectionStart ?? 0)
  }

  const listId = React.useId()

  return (
    <div className={`mx-auto w-full max-w-2xl p-6 ${className}`}>
      <div className="relative">
        {/* -- Trigger menu, above the field so it never covers the draft -- */}
        {open ? (
          <div
            id={listId}
            role="listbox"
            aria-label={trigger?.char === '@' ? 'Sources' : 'Commands'}
            className="absolute bottom-full start-0 z-20 mb-2 max-h-60 w-full overflow-y-auto rounded-xl border border-border/60 bg-card p-1.5 shadow-2xl"
          >
            <p className="px-2.5 py-1.5 text-xs font-bold uppercase tracking-wider text-muted-foreground">
              {trigger?.char === '@' ? 'Attach a source' : 'Run a command'}
            </p>

            {results.map((item, i) => {
              const Icon = item.kind ? KIND_ICON[item.kind] : Slash
              const active = i === highlight

              return (
                <div
                  key={item.id}
                  id={`${listId}-${item.id}`}
                  role="option"
                  aria-selected={active}
                  onMouseEnter={() => setHighlight(i)}
                  // Mouse down, not click: click fires after blur, and blur
                  // has already torn the menu down.
                  onMouseDown={(e) => {
                    e.preventDefault()
                    accept(i)
                  }}
                  className={`flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-sm ${
                    active ? 'bg-primary/10 text-foreground' : 'text-muted-foreground'
                  }`}
                >
                  <Icon aria-hidden className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : ''}`} />
                  <span className="truncate font-medium text-foreground">{item.label}</span>
                  {item.hint ? (
                    <span className="ms-auto shrink-0 truncate text-xs text-muted-foreground">
                      {item.hint}
                    </span>
                  ) : null}
                </div>
              )
            })}
          </div>
        ) : null}

        <div className="rounded-2xl border border-border/60 bg-card p-2 shadow-sm focus-within:ring-2 focus-within:ring-ring">
          {/* -- Attachment chips ------------------------------------- */}
          {chips.length > 0 ? (
            <ul className="flex flex-wrap gap-1.5 px-1.5 pb-2 pt-1">
              {chips.map((chip) => (
                <li key={chip}>
                  <span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/60 py-1 ps-2 pe-1 text-xs">
                    <FileText aria-hidden className="h-3 w-3 text-muted-foreground" />
                    {chip}
                    <button
                      type="button"
                      onClick={() => setChips((list) => list.filter((c) => c !== chip))}
                      className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      <X aria-hidden className="h-3 w-3" />
                      <span className="sr-only">Remove {chip}</span>
                    </button>
                  </span>
                </li>
              ))}
            </ul>
          ) : null}

          <textarea
            ref={fieldRef}
            rows={1}
            value={draft}
            role="combobox"
            aria-expanded={open}
            aria-controls={open ? listId : undefined}
            aria-activedescendant={open ? `${listId}-${results[highlight]?.id}` : undefined}
            aria-autocomplete="list"
            aria-label={placeholder}
            placeholder={placeholder}
            onChange={(e) => {
              setDraft(e.target.value)
              setCaret(e.target.selectionStart ?? 0)
              grow(e.target)
            }}
            onKeyUp={syncCaret}
            onClick={syncCaret}
            onKeyDown={onKeyDown}
            className="max-h-40 w-full resize-none bg-transparent px-2 py-1.5 text-sm leading-relaxed outline-none placeholder:text-muted-foreground border border-transparent"
          />

          {/* -- Toolbar ---------------------------------------------- */}
          <div className="flex items-center gap-1 px-1 pt-1">
            <ToolbarButton icon={<Paperclip className="h-4 w-4" />} label="Attach a file" />
            <ToolbarButton
              icon={<AtSign className="h-4 w-4" />}
              label="Mention a source"
              onClick={() => {
                const next = `${draft}${draft.endsWith(' ') || draft === '' ? '' : ' '}@`
                setDraft(next)
                setCaret(next.length)
                fieldRef.current?.focus()
              }}
            />

            {/* -- Model picker -------------------------------------- */}
            <div className="relative">
              <button
                type="button"
                aria-haspopup="listbox"
                aria-expanded={modelOpen}
                // Same shape as the combobox above: only while the list is
                // mounted, so the IDREF always resolves.
                aria-controls={modelOpen ? `${listId}-models` : undefined}
                onClick={() => setModelOpen((v) => !v)}
                onBlur={() => setModelOpen(false)}
                className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <Sparkles aria-hidden className="h-3.5 w-3.5" />
                {model}
                <ChevronDown aria-hidden className="h-3 w-3" />
              </button>

              {modelOpen ? (
                <ul
                  id={`${listId}-models`}
                  role="listbox"
                  aria-label="Model"
                  className="absolute bottom-full start-0 z-20 mb-1.5 w-44 rounded-xl border border-border/60 bg-card p-1 shadow-2xl"
                >
                  {models.map((name) => (
                    <li key={name}>
                      <button
                        type="button"
                        role="option"
                        aria-selected={name === model}
                        onMouseDown={(e) => {
                          e.preventDefault()
                          setModel(name)
                          setModelOpen(false)
                        }}
                        className={`w-full rounded-lg px-2.5 py-1.5 text-start text-xs transition-colors hover:bg-muted ${
                          name === model ? 'font-semibold text-foreground' : 'text-muted-foreground'
                        }`}
                      >
                        {name}
                      </button>
                    </li>
                  ))}
                </ul>
              ) : null}
            </div>

            <div className="ms-auto flex items-center gap-1">
              <button
                type="button"
                aria-pressed={dictating}
                onClick={() => setDictating((v) => !v)}
                className={`rounded-lg p-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
                  dictating
                    ? 'bg-rose-500/10 text-rose-500'
                    : 'text-muted-foreground hover:bg-muted hover:text-foreground'
                }`}
              >
                <Mic aria-hidden className="h-4 w-4" />
                <span className="sr-only">{dictating ? 'Stop dictation' : 'Dictate'}</span>
              </button>

              <button
                type="button"
                disabled={draft.trim().length === 0}
                onClick={() => setDraft('')}
                className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-opacity disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                <ArrowUp aria-hidden className="h-4 w-4" />
                <span className="sr-only">Send</span>
              </button>
            </div>
          </div>
        </div>
      </div>

      <p className="mt-2 px-1 text-center text-xs text-muted-foreground">
        <kbd className="rounded border border-border/60 bg-muted px-1 py-0.5 font-mono">Enter</kbd>{' '}
        to send ·{' '}
        <kbd className="rounded border border-border/60 bg-muted px-1 py-0.5 font-mono">
          Shift↵
        </kbd>{' '}
        for a new line
      </p>
    </div>
  )
}

function ToolbarButton({
  icon,
  label,
  onClick,
}: {
  icon: React.ReactNode
  label: string
  onClick?: () => void
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
    >
      <span aria-hidden>{icon}</span>
      <span className="sr-only">{label}</span>
    </button>
  )
}

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

import { ChatPromptBar } from '@/components/chat-prompt-bar'

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
placeholderstring'Ask anything — @ for sources, / for commands'
sourcesPromptSource[]DEFAULT_SOURCES
commandsPromptCommand[]DEFAULT_COMMANDS
modelsstring[]DEFAULT_MODELS
attachmentsChips shown above the field, as if already attached.string[]['Q3-forecast.xlsx']
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-prompt-bar.html
<!--
  Composer with @ Sources and / Commands — 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="mx-auto w-full max-w-2xl p-6 ">
  <div class="relative">
    <div class="rounded-2xl border border-border/60 bg-card p-2 shadow-sm focus-within:ring-2 focus-within:ring-ring">
      <ul class="flex flex-wrap gap-1.5 px-1.5 pb-2 pt-1">
        <li>
          <span class="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/60 py-1 ps-2 pe-1 text-xs"><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 w-3 text-muted-foreground" 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>Q3-forecast.xlsx<button type="button" class="rounded p-0.5 text-muted-foreground transition-colors hover:bg-background hover:text-foreground 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-x h-3 w-3" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg><span class="sr-only">Remove Q3-forecast.xlsx</span></button></span>
        </li>
      </ul>
      <textarea rows="1" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-label="Ask anything — @ for sources, / for commands" placeholder="Ask anything — @ for sources, / for commands" class="max-h-40 w-full resize-none bg-transparent px-2 py-1.5 text-sm leading-relaxed outline-none placeholder:text-muted-foreground border border-transparent"></textarea>
      <div class="flex items-center gap-1 px-1 pt-1">
        <button type="button" class="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
          <span aria-hidden="true">
            <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-paperclip h-4 w-4" aria-hidden="true">
              <path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path>
            </svg>
          </span>
          <span class="sr-only">Attach a file</span>
        </button>
        <button type="button" class="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
          <span aria-hidden="true">
            <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-at-sign h-4 w-4" aria-hidden="true">
              <circle cx="12" cy="12" r="4"></circle>
              <path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"></path>
            </svg>
          </span>
          <span class="sr-only">Mention a source</span>
        </button>
        <div class="relative">
          <button type="button" aria-haspopup="listbox" aria-expanded="false" class="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground 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.5 w-3.5" 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>Balanced<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-3 w-3" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></button>
        </div>
        <div class="ms-auto flex items-center gap-1">
          <button type="button" aria-pressed="false" class="rounded-lg p-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-muted-foreground hover:bg-muted hover:text-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-mic h-4 w-4" aria-hidden="true">
              <path d="M12 19v3"></path>
              <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
              <rect x="9" y="2" width="6" height="13" rx="3"></rect>
            </svg>
            <span class="sr-only">Dictate</span>
          </button>
          <button type="button" disabled="" class="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-opacity disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
            <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-up h-4 w-4" aria-hidden="true">
              <path d="m5 12 7-7 7 7"></path>
              <path d="M12 19V5"></path>
            </svg>
            <span class="sr-only">Send</span>
          </button>
        </div>
      </div>
    </div>
  </div>
  <p class="mt-2 px-1 text-center text-xs text-muted-foreground"><kbd class="rounded border border-border/60 bg-muted px-1 py-0.5 font-mono">Enter</kbd> to send · <kbd class="rounded border border-border/60 bg-muted px-1 py-0.5 font-mono">Shift↵</kbd> for a new line</p>
</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

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

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