Skip to main content
AI tool-stations have a UI vocabulary that doesn’t fit generic component libraries: token meters that update during streaming, generation cards with regenerate buttons, image galleries with download/copy/share affordances, history rows with model badges. Building each from scratch wastes days of styling and edge-case work. vibestrap ships a 17-component AI primitives library in src/components/ai/, each wrapping shadcn/ui with these patterns built in. Every component is purely presentational: no global state, no hidden side effects, no Suspense boundaries. Drop one in, replace any by copy-paste.

Prerequisites

  • shadcn/ui already wired (it is, in the scaffold).
  • Tailwind classes resolved by your theme (@/lib/utils cn).
  • Read src/components/ai/index.ts for the canonical export list.

API reference

Display (5)

<TokenMeter inputTokens={120} outputTokens={480} total?={4096} /> — Stacked bar of input vs output tokens. Pass total to show context-window utilization. <LatencyPill totalMs={840} ttftMs?={120} /> — Pill showing total + TTFT for streaming calls. <ProviderPill provider="openai" model?="gpt-4o-mini" /> — Color-coded provider chip; one color per AIProviderName. <CreditsBadge label?="credits" hideWhileLoading?={true} /> — Header badge that wraps useCredits(). Renders nothing for anonymous users. <StreamingText text={text} isStreaming?={true} />whitespace-pre-wrap with a blinking caret while streaming.
<StreamingText text={text} isStreaming={isStreaming} />

Chat (2)

<ChatBubble author="user" trailing?={<CopyButton text=".." />}>...</ChatBubble> — Single message bubble. author is 'system' | 'user' | 'assistant'; the user variant right-aligns.
<ChatBubble author="assistant" trailing={<CopyButton text={msg} iconOnly />}>
  {msg}
</ChatBubble>
<ChatList trigger={messages.length}>...</ChatList> — Auto-scrolling container. Smart: only sticks to the bottom when the user is already there. Pass anything that changes between scrolls into trigger.

Containers (7)

<GenerationCard title?="GPT-4o-mini" actions?={...} footer?={...}>...</GenerationCard> — Card chrome for any AI result: title row + content + metadata footer.
<GenerationCard
  title="gpt-4o-mini"
  actions={<CopyButton text={text} iconOnly />}
  footer={<LatencyPill totalMs={ms} />}
>
  <StreamingText text={text} isStreaming={isStreaming} />
</GenerationCard>
<ImageGallery images={[{ url }]} aspect?="square" onClick?={...} /> — Responsive 1/2/3-column grid with hover download buttons. <EmptyState icon?={Icon} title="..." description?="..." action?={...} /> — Dashed-border placeholder for “no data yet” zones. <ErrorState title?="..." description?="..." onRetry?={...} action?={...} /> — Destructive-tinted error card with optional retry button. <TaskProgress status={state.status} progress?={0.4} label?="Rendering" /> — Bar + label tied to useTask TaskStatus. Indeterminate when progress is omitted. <HistoryRow row={row} /> — Single line in an ai_call history list. Takes the AICallHistoryRow shape from useHistory. Token columns render as for image calls (where the provider returned no token counts). <InsufficientCreditsBanner needed={50} balance={3} topUpHref?="/pricing" /> — Amber banner with a “Top up” CTA. Render when chat() returns INSUFFICIENT_CREDITS.
if (isInsufficientCredits(r)) return <InsufficientCreditsBanner needed={r.needed} balance={r.balance} />;

Inputs (3)

<ModelPicker models={[{ id, label, hint? }]} value={id} onChange={fn} /> — Dropdown for selecting a model. hint shows below the label (use it for latency / model speed).
<ModelPicker
  models={[
    { id: 'gpt-4o-mini', label: 'GPT-4o mini', hint: 'fast · cheap' },
    { id: 'gpt-4o', label: 'GPT-4o', hint: 'flagship' },
  ]}
  value={model}
  onChange={setModel}
/>
<RegenerateButton onClick={fn} isLoading?={true} iconOnly?={true} /> — Sit it in a GenerationCard.actions slot. Spinner replaces the icon while loading. <CopyButton text="..." iconOnly?={true} resetMs?={1500} /> — Copies to clipboard, flashes a check for 1.5s. Falls back to document.execCommand('copy') on old browsers.

Verify it works

  1. <TokenMeter inputTokens={100} outputTokens={300} /> — bar should show roughly 1/4 vs 3/4 split.
  2. Mount <CreditsBadge /> in your header. Sign in. Make a paid call. The number updates after refetch() (manually call it in your post-call effect).
  3. Visit any of the demo pages — chat-demo, image-demo, document-demo — to see primitives composed in real flows.

Common pitfalls

  • Server components importing client primitives. CreditsBadge, CopyButton, ModelPicker, ChatList, RegenerateButton are 'use client'. Server components that import them break the build. Wrap or promote the parent to a client component.
  • <TokenMeter total={0} />. Division-by-zero is guarded (safeDenom = 1), but the bar will show 100%+. Either pass a real total or omit the prop.
  • <ChatList> without a trigger prop. Auto-scroll won’t fire on new messages. Pass trigger={[messages.length, streamingText]}.
  • Replacing one but not its sibling. These primitives share visual rhythm (same paddings, font sizes). If you fork <LatencyPill> to add an icon, also audit <ProviderPill> so the row still aligns.
  • No dollar / cost component. We deliberately don’t ship a CostBadge — see Observability for why. If your product genuinely needs to show $ in the UI, add a column to ai_call with your own pricing logic and render it however you like.

Official docs