@chai-ui/react
v1.4.1
Published
Chai UI React components — smart, drop-in UI for AI apps: Composer, Media Analyzer, Result Card, Edit Card, Side Panel, and the useComposer hook.
Maintainers
Readme
@chai-ui/react
Chai UI for React: drop-in components for the input and output surfaces of generative-media apps. Everything here is controlled — you own the state, these components render it and tell you when it changes.
Building with it, or pointing a coding agent at it? Read
DESIGN.md (shipped in the package) for the usage rules:
which component to pick, which tokens exist, the built-in AI states, and
what not to build.
Chai's components support WCAG 2.2 AA: tested with axe and automated
keyboard and focus checks, not by hand with a screen reader. DESIGN.md §6
has the details. Focus rings are visible by default, as WCAG requires.
data-chai-focus-ring="hidden" on an ancestor hides them, for preview or
showcase surfaces only. Composer's promptLabel names the prompt field
(default: the placeholder), and ResultCard's altText sets image alt
text (default: the prompt).
Quick start: Composer
Composer is the bar-layout input surface: a prompt field, an attach
menu, a use-case chip, model/aspect-ratio pickers, and submit — all driven
by which props you supply, not by four separate components.
import { useState } from "react";
import "@chai-ui/tokens/css";
import "@chai-ui/react/style.css";
import { Composer, type ComposerAttachment, type ComposerSubmitPayload } from "@chai-ui/react";
function App() {
const [value, setValue] = useState("");
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
function handleSubmit(payload: ComposerSubmitPayload) {
// payload = { value, attachments, useCase, modelId, aspectRatio } —
// everything Composer tracks, assembled for you. Send it to whichever
// model/provider you've set up; Composer never calls one itself.
console.log(payload);
}
return (
<Composer
value={value}
onChange={setValue}
attachments={attachments}
onAttachmentsChange={setAttachments}
onSubmit={handleSubmit}
/>
);
}That's the minimal case — no model picker, no aspect ratio, no attach capability beyond the default file picker. Every other control is additive:
<Composer
value={value}
onChange={setValue}
attachments={attachments}
onAttachmentsChange={setAttachments}
// A use-case chip + model/aspect-ratio pickers only appear once `useCase`
// is set — wire it from the attach menu's own picks, or set it yourself.
useCase={useCase}
onClearUseCase={() => setUseCase(null)}
onAttachMenuSelect={(action) => {
if (action === "create-image") setUseCase({ kind: "image", label: "Image" });
}}
// The Model menu lists Chai's suggested models for the use case, unless
// you pass your own `models` (see "Suggested models"). Nothing is picked
// for the person: submit waits until they pick a model or turn on
// auto-select. Without `onModelChange` there's no menu, and the list's
// first model runs.
modelId={modelId}
onModelChange={setModelId}
// An "Auto-select model" switch appears at the top of the model menu so
// the end user can flip it — omit `showAutoSelectToggle` to keep it a
// fixed, builder-only setting with no visible control instead.
autoSelectModel={autoSelectModel}
showAutoSelectToggle
onAutoSelectModelChange={setAutoSelectModel}
// The Aspect ratio menu offers what the selected models take. Suggested
// models carry their `aspectRatios`; give your own models theirs, e.g.
// ["1:1", "16:9", "9:16"]. A model without them (image-to-video follows
// the input image) shows no menu.
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
// Omit onEnhance entirely to hide the "optimize prompt" button — e.g.
// before you've wired an engine that can actually run the rewrite.
onEnhance={(currentValue) => runEnhance(currentValue).then(setValue)}
onSubmit={handleSubmit}
/>The full prop list — including attachments' file-reading behavior,
onUnsupportedFile, and disabled/submitDisabled — is documented as
JSDoc directly on ComposerProps in src/Composer.tsx; every prop and
event follows the same value/onChange shape used everywhere else in
this package.
See the Composer page on the docs site for a live example with every opt-in feature.
No use case picked: a text request
A prompt typed with no use case picked is a text request
(TEXT_USE_CASE): it gets a text reply rather than doing nothing. That's
how to use the Composer as a plain chat bar for an LLM:
- The model:
useComposersends it totextEnginewithtextModelwhen you set them, otherwise toChaiProvider's reasoning model (default Claude Opus 5 on OpenRouter). Without aChaiProvideror atextEngine, a mock text engine answers, so wrap your app in<ChaiProvider>for real replies. - No Model menu: the suggested lists cover image and video only, so a
text request shows no Model menu. To offer a choice of text models, pass
your own
models. - The reply streams into the
ResultCardas it's written, andcancel()stops it, keeping what has arrived.
<ChaiProvider>
<Composer value={value} onChange={setValue} onSubmit={submit} submitting={busy} onAbort={cancel} />
{run && <ResultCard results={run.results} prompt={run.request.prompt} onAction={() => {}} />}
</ChaiProvider>For an app that's mostly about images, set the default instead; it's never shown as a chip, and it brings the suggested image models and the Aspect ratio menu:
<Composer defaultUseCase={{ kind: "image", label: "Image" }} … />Several models, several use cases (builder opt-ins, off by default)
// Several models: each produces its own result, paged inside one ResultCard.
<Composer multiSelectModels modelIds={modelIds} onModelIdsChange={setModelIds} models={models} … />
// Image and Video from one prompt (advanced): a Model menu per use case,
// one model each picked by hand (no auto-select), and submit waits for both. Only Image and Video combine;
// nextUseCases makes any other pick replace the rest.
<Composer
multiSelectUseCases
useCases={useCases}
onUseCasesChange={setUseCases}
onAttachMenuSelect={(action) => {
const picked = useCaseFor(action); // your mapping from menu item to use case
if (picked) setUseCases((prev) => nextUseCases(prev, picked));
}}
modelsByKind={{ image: imageModels, video: videoModels }}
modelIds={modelIds}
onModelIdsChange={setModelIds}
…
/>Either way onSubmit receives selections: one { useCase, modelIds, models }
entry per use case. An image arrives in seconds and a video can take
minutes, so show each use case's run in its own ResultCard (useComposer's
runs, one per use case) rather than paging them together.
With autoSelectModel on (not with multiSelectUseCases), the trigger reads "Auto-select" and
useComposer picks the model at submit: the reasoning model reads the
prompt and media and chooses from that use case's models. When the end
user owns the switch (showAutoSelectToggle), auto-select belongs to the
use case it was turned on for: picking another use case calls
onAutoSelectModelChange(false), so the new one's model is picked by hand. Pass
onModelChange (or onModelIdsChange) to the hook too, so the trigger
shows the pick:
const { submit, routing } = useComposer({ engine, onModelChange: setModelId });If routing can't run (no ChaiProvider, a failed reasoning call, an
unlisted reply), the request runs on the first listed model and
onRoutingFallback says why.
Attached images and the picked ratio
Attaching images doesn't change the Model menu or hide Aspect ratio. Each
suggested model names its image-taking Fal endpoint (withImages), and the
Fal engine calls it when images are attached. The picked ratio always
wins: image models take it directly, and a video's start image is fitted
to it before it's sent, cropped around its center by default or padded
with black bars:
const { submit } = useComposer({ engine, startImageFit: "pad" }); // default "crop"Quality checks: evaluations
Each result can carry pass or fail tags, shown on the ResultCard's flip side. They come from two places, and you can use either or both:
- Your backend: add a top-level
evaluations: [{ id, label, passed }]field to the JSON your route returns (for a stream, send it as its owndata:chunk). The Fal and OpenRouter engines read it. - The opt-in judge: a model you name checks each text or image result
against your criteria, one tag per check. It runs on the reasoning engine
(
ChaiProvider). It's off by default, with no default model. WithoutjudgeChecksit runs one general "Matches prompt" check. What else counts as good is up to your app.
const { submit } = useComposer({
engine,
judgeModel: "google/gemini-3.5-flash",
judgeChecks: [
// Your criteria, in your words. This one is an example for a marketing app's images.
{ id: "on-brand", label: "On brand", criterion: "Photographic and polished, never cartoonish.", kind: "image" },
...DEFAULT_EVALUATION_CHECKS,
],
});The judge's prompt is buildJudgePrompt (exported, so you can read it).
Pass judgePrompt to replace it, calling buildJudgePrompt to add to it
rather than rewrite it.
Analytics: onEvent
Every component reports what the person does and sees, one ChaiEvent
each, for whatever analytics tool you use. Write one function and pass it
to each component you use:
import posthog from "posthog-js";
import type { ChaiEvent } from "@chai-ui/react";
const track = (e: ChaiEvent) => posthog.capture(e.name, e.properties);
<Composer onEvent={track} … />
<ResultCard onEvent={track} … />
<SidePanel onEvent={track} … />
<MediaAnalyzer onEvent={track} … />
<EditCard onEvent={track} … />Events cover submits, Stop, use case, model and option picks, attachments, Enhance, voice input, results shown (once each), result actions, paging, the details side; the side panel opening, closing, New chat, prompt tips (by position) and its page chip; the media analyzer's attach and remove, model, auto-select and prompt length picks, submit, Stop and each analysis finishing (done or error); and the edit card's regions added, confirmed and removed, zoom, paging through versions, download and share. Moving and resizing a region aren't reported. They're front end only: no prompt text, file names, URLs or outputs, only lengths, counts and Chai's own ids. Track model calls, latency and cost on your server, where they're accurate.
Where the bar sits: menuPlacement
Every menu in the bar (the + menu, Model, Aspect ratio, Voice, Genre,
Frame rate) opens the same way. Tell Composer where the bar is docked:
<Composer menuPlacement="above" {...props} /> // docked to the bottom of the screen, like a chat app
<Composer menuPlacement="below" {...props} /> // docked to the topLeave it out ("auto") for a bar that sits in the page: each menu is
measured when it opens and goes below if it fits, else above if that fits,
else toward the side with more room, so it's never cut off by the edge of
the screen.
Your own font
style.css loads Roboto, Material Design 3's typeface. If your app has its
own font, import @chai-ui/react/style-without-fonts.css in its place (the
same styles, without Roboto) and point the components at your font:
:root { --chai-type-family-material: "Your Font", system-ui, sans-serif; }Dark theme
The components have a dark theme. It's opt-in: set data-chai-theme="dark"
on <html>, or on any element, and every component inside it switches.
<html data-chai-theme="dark">Chai doesn't follow the system setting on its own, because your app decides
its theme. Following it, overriding a single color, and the dark values
themselves are in the
@chai-ui/tokens README.
What else this package ships
MediaAnalyzer— drop in an image, video, or audio clip and get a generation-ready prompt back, with kind-aware model menus (suggested OpenRouter models unless you pass your own) and a prompt length picker. Wire it withuseMediaAnalyzer(), which sends the media toChaiProvider's reasoning model and returns the prompt:onSubmit={submit} submitting={analyzing} submitError={error}.ResultCard— renders one prompt's generated results (image, video, audio, or text) with like/dislike/retry/download/share, a configurable "more" menu, pagination across multiple results, and an info flip showing the prompt and model metadata. Live example: chai-ui.com/docs/components/result-card.EditCard— edit an attached image by marking regions on it, each with its own instruction, then page through the edits as versions. See "Editing an image" below.SidePanel— a panel beside your page with a Composer and its answers, with opt-in page context and prompt tips. See "A side panel" below.- Primitives (
src/primitives/) —Toggle(an MD3 switch),SearchMenu(the model/option menu behind every picker — Chai's own rather than Material's<md-menu>; DESIGN.md §3 says why), andPagination(a progress-style pager any paginated surface can reuse). The components are built from these; they're also exported directly if you're assembling your own layout.
Suggested models
You don't have to write model lists. Chai ships suggested lists, checked against each provider's own catalog, and the components use them when you pass none:
| Use case | Export | Provider |
|---|---|---|
| Create image | SUGGESTED_IMAGE_MODELS | Fal |
| Create video from a prompt | SUGGESTED_TEXT_TO_VIDEO_MODELS | Fal |
| Create video from an image | SUGGESTED_IMAGE_TO_VIDEO_MODELS | Fal |
| Edit image | SUGGESTED_EDIT_MODELS | Fal |
| Media analysis, per media kind | SUGGESTED_ANALYSIS_MODELS | OpenRouter |
- Composer offers the list for the current use case
(
suggestedModels(useCase)). It never picks for the person: with a Model menu, submit waits for a pick or auto-select; with no menu, the list's first model runs. MediaAnalyzer offers the analysis lists. - Your own list wins. Pass
models(ormodelsByKind) to replace a list, or[]for no Model menu. Nothing is required. - Each suggested model knows its inputs: its aspect ratios, and for Fal
the field its image goes in (
falInput), so the Fal engine sends exactly what that model expects. - They change only through package releases. Providers change faster, so check them while setting up your engine. It needs no key, runs no model and costs nothing:
import { checkSuggestedModels } from "@chai-ui/react";
const { ok, problems } = await checkSuggestedModels();
// problems: [{ provider: "fal", modelId, problem: "No longer takes these aspect_ratio values: 21:9." }]It confirms each model still exists and is active, still takes the aspect ratios and image field Chai sends (Fal), and still accepts its media kind (OpenRouter). If something changed, pick another model or update the package once a release catches up.
useComposer — wiring Composer to a real engine
import { ChaiProvider, useComposer, createFalEngine, Composer, ResultCard } from "@chai-ui/react";
// The reasoning models write text (Enhance, routing, text requests) and
// are separate from the media engine. With no props,
// Claude Opus 5 handles text and images, Gemini 3.8 Flash handles video
// and audio, both on `createOpenRouterEngine()`. Without a provider,
// Composer's Enhance button stays hidden.
function Root() {
return (
<ChaiProvider>
<App />
</ChaiProvider>
);
}
function App() {
const { run, runs, submit, cancel, enhance, enhancing } = useComposer({
engine: createFalEngine({ outputKind: "image" }),
});
// Every model's result from every use case, paged in one card.
const results = runs.flatMap((r) => r.results);
const busy = results.some((r) => r.status === "queued" || r.status === "running");
return (
<>
<Composer
value={value}
onChange={setValue}
onSubmit={submit}
submitting={busy}
onAbort={cancel}
onEnhance={enhance ? (v) => enhance(v).then(setValue) : undefined}
enhancing={enhancing}
// ...the rest of Composer's own props, as above
/>
{run && (
<ResultCard
results={results}
prompt={run.request.prompt}
onAction={(action, result) => {
// like / dislike / retry / download / share / your "more" actions
}}
/>
)}
</>
);
}Enhance's instruction is buildEnhancePrompt (exported, so you can read
it). Pass enhancePrompt to useComposer to replace it, calling
buildEnhancePrompt to add to it rather than rewrite it:
useComposer({
engine,
enhancePrompt: (value, mode, field) => `${buildEnhancePrompt(value, mode, field)}\n\nKeep our brand's warm, natural style.`,
});submit takes Composer's ComposerSubmitPayload directly — pass it
straight to onSubmit. It turns each selection into a real
Request/Run/Result: creates a "queued" Result per model up
front, then folds each one to "running" → "done"/"error" as the
engine settles it. run is the first Run (or null before the first
submit); runs holds one per use case when multiSelectUseCases is on.
Pass ResultCard the results of every run, as above: it pages through
them, and each page shows its own kind (image, video, audio or text).
Passing only run.results would hide every use case after the first.
The server route. createFalEngine(), createVercelEngine() and the
OpenRouter reasoning engine call /api/chai/* on your server, which adds
the API keys. Add it with one file, then set FAL_KEY, OPENROUTER_API_KEY
or AI_GATEWAY_API_KEY:
// Next.js: app/api/chai/[...path]/route.ts
import { createChaiHandler } from "@chai-ui/core/server";
export const { GET, POST, PUT } = createChaiHandler();Other frameworks, and the authorize / allowedModels settings to add
before going live, are in the @chai-ui/core README.
Without a server route. In a desktop app (Tauri, Electron), write an
engine that hands each run to your app's backend. Pass it to
useComposer({ engine: desktopEngine }) and to
<ChaiProvider reasoningEngine={desktopEngine}>. The
@chai-ui/core README
has a Tauri example, voice input, and what Stop does there.
Every default is a suggestion. Swap any of them:
<ChaiProvider
reasoningModel="your/text-and-image-model"
// Cheaper video and audio analysis; kinds you leave out keep their default.
reasoningModelByKind={{ video: "qwen/qwen3.8-omni-flash", audio: "qwen/qwen3.8-omni-flash" }}
reasoningEngine={yourEngine}
/>The defaults are exported as DEFAULT_REASONING_MODEL and
DEFAULT_REASONING_MODEL_BY_KIND.
useComposer's own reasoningEngine + reasoningModel override the
provider's, but only when both are set. Text requests go to textEngine
(default: the reasoning engine, then a mock text engine), using
textModel (default: the reasoning model) when no model is picked. Other kinds go to engineByKind[kind], then engine. With no
model picked, the engine's own default model runs.
Stop. cancel() stops the current submit: wire it to Composer's
onAbort, with submitting true while results are queued or running.
Unfinished results turn "cancelled" (ResultCard shows "Stopped"; a
streamed text result keeps what arrived), and a new submit cancels the
previous one. Cancellation is best effort at the provider: Fal removes a
queued request and signals a running one, which may still finish;
OpenRouter stops a streaming request, and its billing, only for
providers that support it (not Google); a non-streaming request keeps
running. A custom engine receives signal in generate(): cancel at the
provider if it can, then reject with an AbortError (abortError()).
Editing an image: EditCard
The attach menu's "Edit image" puts Composer in edit mode. Set the use case
to EDIT_IMAGE_USE_CASE, pass the attached image to useComposer as
editImage, and drop in the card:
import { Composer, EditCard, EDIT_IMAGE_USE_CASE, SUGGESTED_EDIT_MODELS, useComposer } from "@chai-ui/react";
const editImage = useCase?.edit ? (attachments.find((a) => a.kind === "image") ?? null) : null;
const { run, submit, edit } = useComposer({ engine: createFalEngine({ outputKind: "image" }), editImage });
// The pick decides how many regions it takes (Flux 3 Image's until one is picked).
const model = SUGGESTED_EDIT_MODELS.find((m) => m.id === modelId) ?? SUGGESTED_EDIT_MODELS[0];
{edit && <EditCard {...edit} maxRegions={model?.maxRegions} />}
{!edit && run && <ResultCard results={run.results} prompt={run.request.prompt} onAction={…} />}
<Composer
// ...your other Composer props
useCase={useCase}
onAttachMenuSelect={(action) => {
if (action === "edit-image") setUseCase(EDIT_IMAGE_USE_CASE);
}}
regions={edit?.regions}
onRegionsChange={edit?.onRegionsChange}
onSubmit={submit}
/>- Regions. "New region" adds a box with an instruction field. The check
adds it to the prompt (a colored chip in the Composer); trash removes it.
Drag to move, drag a corner to resize; by keyboard, arrows move and Shift
- arrows resize. Zoom keeps the image inside the card; drag to pan.
- Submitting. An edit needs the image plus a whole-image prompt, a region, or both. Regions carry their own instructions, so the prompt can stay empty.
- Models. In edit mode Composer offers
SUGGESTED_EDIT_MODELS: Flux 3 Image first, which reads each region as a box, then Nano Banana Pro and GPT Image 2. Offer any edit model you like; one without aregionFormatgets its regions described in words, which is less precise. Change how the prompt is built withuseComposer'seditPrompt. - Versions. Each edit's result is a new version, shown in the card with dots to page back. The next edit applies to the version showing.
- Images only for now.
A side panel: SidePanel
A panel on one side of your page with a Composer and its answers, for quick model use without leaving the page. People can ask about the page they're on or about anything else:
import { SidePanel } from "@chai-ui/react";
<div style={{ display: "flex" }}>
<main style={{ flex: 1 }}>{page}</main>
<SidePanel
title="Northwind Assistant"
description="Ask about this page, or anything else."
pageContext
promptTips={["Summarize this page", "What changed for free accounts?", "Draft a polite out-of-office reply"]}
/>
</div>- Answers come from your
ChaiProvider's reasoning engine, or passtextEngine(andtextModel). There's no model menu, aspect ratio or Enhance in the panel; voice input works as on the Composer (voiceInput). - Page context is off by default. With
pageContext, the panel reads the page's title, URL, selected text and main text (readPageContext:<main>, then<article>, then<body>, capped at 12,000 characters) and sends it with each question. A "This page" chip shows it's on; the person can remove it to ask something else, and put it back from the+menu. Pass a function instead oftrueto read the page your own way. Mark anything to leave out withdata-chai-page-context="skip". Sending a page's text to a model is your decision to make on purpose. - Prompt tips: two to four short examples under "Getting started"
(
promptTipsLabel). A tap sends one. Include at least one that isn't about the page. - Layout:
side="right"(default) or"left".mode="push"(default) takes its own width beside your content, so place it as a flex sibling;mode="overlay"floats over the page.containedkeeps an overlay inside its positioned parent. Below 600px wide, both open as a full-screen sheet.--chai-side-panel-widthsets the width (400px). - Opening: a launcher button opens it; Close clears the conversation,
and New chat starts over without closing. Control it with
openandonOpenChange, and setlauncher={false}to use your own button. Opening moves focus to the prompt, Esc closes the panel (after any open menu), and closing returns focus to the launcher. - Your mark:
logoreplaces the default sparkle in the launcher, the greeting (44px) and the header (28px), shown as given (animgorsvgfills the box) without the sparkle's green tile. - Follow-ups work: the last six exchanges go with each question
(
buildSidePanelPrompt). - Answers style:
answers="simple"(default) shows text on the panel with like, dislike, retry and copy;"card"puts each answer in a ResultCard.footeradds a note under the Composer, such as a disclaimer. - Analytics:
onEventreports opening, closing, New chat, prompt tips (by position) and the page chip, plus the Composer's and the answers' own events.
