AI generate button
The sparkles button beside any descriptive field: it drafts text, shimmers while working and offers undo.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const nameId = React.useId(); const descriptionId = React.useId(); const [name, setName] = React.useState("Bank detail change"); const [description, setDescription] = React.useState(""); const previous = React.useRef(""); return ( <div className="grid w-full max-w-lg gap-4 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex flex-col gap-1.5"> <Label htmlFor={nameId}>Name</Label> <Input id={nameId} value={name} onChange={(e) => setName(e.target.value)} /> </div> <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between"> <Label htmlFor={descriptionId}>Description</Label> <AIGenerateButton onGenerate={() => { previous.current = description; return `Use for ${name.toLowerCase()} requests from suppliers. Capture the new account, the verified callback and who approved it, so AP can act without a follow-up call.`; }} onResult={setDescription} onUndo={() => setDescription(previous.current)} toastTitle="Description drafted" /> </div> <Textarea id={descriptionId} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="When agents should pick this type" /> </div> <div className="flex justify-end"> <Button type="button" size="sm" onClick={() => toast.add({ type: "success", title: "Ticket type saved", }) } > Save </Button> </div> </div> );}Usage#
AI generate button is the sparkles action that drafts text for a field. It sits beside the field's label (AIGenerateButton) or inside the field's corner (AIFieldAction), shimmers while it works, hands the text to your field and confirms with a toast that offers Undo. Every free-text field that describes something, such as an agent description, a ticket summary, a macro or a campaign message, gets one. The thing people get wrong is weight: it is a quiet ghost button that helps fill one field, never the view's filled action, and it always leaves a way back.
When to use
- Beside the label of any descriptive textarea: agent, tool and ticket type descriptions, procedure steps, scorecard criteria, skill instructions.
- Inside the corner of a field when the label row is taken or the form is dense, with
AIFieldAction. - Below a field, right-aligned, for a rewrite of existing text: Rewrite from the thread.
- In a composer toolbar to draft a reply from the conversation: Draft reply.
- With
useAIGeneratewhen the trigger has to look different, such as a toolbar button that adds a note.
When not to use
- For long-form prompts. They get the full editor with its slash menu and AI rewrites. Use Prompt editor
- To rewrite, shorten or translate a selection inside text. Use Selection actions
- When the result sends, publishes or changes a record. Propose it and let someone approve. Use Approval card
- For a summary that people read rather than edit. Use Streaming text
- For short structured fields such as names, amounts, dates and IDs. Generating them adds nothing.
- When generating is the view's main action, such as building a report. Use the filled button. Use Button
The One Filled Button Rule
The Quiet Indigo Rule
AI everywhere, without noise
Anatomy#
- Sparkles icon. 12px at
xs, 14px atsm, with the inline-start padding. It pulses softly while pending when motion is allowed. - Label. Generate by default, or a verb for the job. Replaced by Generating… in a shimmer while pending.
- Field action.
AIFieldAction: a 24px icon-only ghost button 6px from the field's top-right (or bottom-right) corner. A pixel grid replaces the sparkles while pending. - Reserved padding.
pr-9on the field, so typed text never runs under the field action. - Undo toast. After the text lands: a toast titled Draft added (or your
toastTitle) with an Undo action whenonUndois set. Not drawn above.
Examples#
Variants and sizes
Ghost at 24px is the default, for label rows. sm is 28px. Outline suits a button below a field or in a sheet footer. Each one toasts Draft added when it finishes.
Press any of them.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import * as React from "react";export function Variants() { const [last, setLast] = React.useState<string | null>(null); const draft = "Thanks for calling Cedarline supplier support. How can I help with your payment today?"; return ( <div className="flex flex-col items-center gap-5"> <div className="flex flex-wrap items-center justify-center gap-3"> <AIGenerateButton onGenerate={() => draft} onResult={() => setLast("ghost, xs")} /> <AIGenerateButton size="sm" onGenerate={() => draft} onResult={() => setLast("ghost, sm")} /> <AIGenerateButton variant="outline" onGenerate={() => draft} onResult={() => setLast("outline, xs")} /> <AIGenerateButton variant="outline" size="sm" onGenerate={() => draft} onResult={() => setLast("outline, sm")} /> </div> <p className="h-4 text-xs text-muted-foreground"> {last ? `Last draft came from the ${last} button.` : "Press any of them."} </p> </div> );}Inside the field
AIFieldAction sits in the corner of a relative wrapper, top-right by default or bottom-right for a tall textarea. The field reserves pr-9 so text never runs under it.
import { AIFieldAction } from "@oration/canon/components/ai/ai-button";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function InsideTheField() { const descriptionId = React.useId(); const greetingId = React.useId(); const [description, setDescription] = React.useState(""); const [greeting, setGreeting] = React.useState(""); const previousDescription = React.useRef(""); const previousGreeting = React.useRef(""); return ( <div className="grid w-full max-w-md gap-4"> <div className="flex flex-col gap-1.5"> <Label htmlFor={greetingId}>Greeting</Label> <div className="relative"> <Input id={greetingId} value={greeting} onChange={(e) => setGreeting(e.target.value)} placeholder="What the agent says first" className="pr-9" /> <AIFieldAction label="Draft a greeting" toastTitle="Greeting drafted" onGenerate={() => { previousGreeting.current = greeting; return "Hi, this is Nora from Cedarline supplier support. How can I help?"; }} onResult={setGreeting} onUndo={() => setGreeting(previousGreeting.current)} /> </div> </div> <div className="flex flex-col gap-1.5"> <Label htmlFor={descriptionId}>Campaign message</Label> <div className="relative"> <Textarea id={descriptionId} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What the agent asks for on the call" className="min-h-24 pr-9" /> <AIFieldAction label="Draft the campaign message" position="bottom-right" toastTitle="Message drafted" onGenerate={() => { previousDescription.current = description; return "Call suppliers whose W-9 is more than 30 days overdue. Explain that Friday's payment run holds their invoices until a signed W-9 is on file, and offer to text the secure upload link."; }} onResult={setDescription} onUndo={() => setDescription(previousDescription.current) } /> </div> </div> </div> );}Improve, with undo
A custom label and pending label, a toast that says what changed and Undo that restores the old text. It is disabled while the field is empty, because there is nothing to improve.
Clear the field to see the disabled state.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function ImproveWithUndo() { const bodyId = React.useId(); const [body, setBody] = React.useState( "hi {{first_name}}, your payment {{payment_id}} went out. let us know if u need the remittance", ); return ( <div className="flex w-full max-w-md flex-col gap-1.5"> <div className="flex items-center gap-2"> <Label htmlFor={bodyId}>Macro message</Label> <div className="ml-auto"> <AIGenerateButton label="Improve with AI" pendingLabel="Improving…" disabled={!body.trim()} onGenerate={() => "Hi {{first_name}}, payment {{payment_id}} went out today. Reply here if you'd like the remittance advice and I'll send it right away." } onResult={setBody} onUndo={() => setBody(body)} toastTitle="Macro improved" toastDescription="Clearer greeting and sign-off. Every variable was kept." simulateMs={1200} /> </div> </div> <Textarea id={bodyId} value={body} onChange={(e) => setBody(e.target.value)} /> <p className="text-xs text-muted-foreground"> Clear the field to see the disabled state. </p> </div> );}When generation fails
A rejected promise leaves the field untouched and toasts the error with Try again. Turn the outage off to see the draft land.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Label } from "@oration/canon/components/label";import { Switch } from "@oration/canon/components/switch";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function Failure() { const [outage, setOutage] = React.useState(true); const [text, setText] = React.useState(""); const fieldId = React.useId(); return ( <div className="flex w-full max-w-md flex-col gap-3"> <Switch label="Simulate an outage" checked={outage} onCheckedChange={setOutage} /> <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between"> <Label htmlFor={fieldId}>Scorecard criterion</Label> <AIGenerateButton label="Suggest" pendingLabel="Suggesting…" onGenerate={() => outage ? new Promise<string>((_, reject) => window.setTimeout( () => reject( new Error( "Model unavailable", ), ), 900, ), ) : "The agent confirms the invoice number before sharing payment details." } onResult={setText} /> </div> <Textarea id={fieldId} value={text} onChange={(e) => setText(e.target.value)} /> </div> </div> );}A short label with a tooltip
When the label is a single verb such as Describe, add tooltip for the sentence that explains it.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function WithTooltip() { const [description, setDescription] = React.useState(""); const fieldId = React.useId(); return ( <div className="flex w-full max-w-sm flex-col gap-1.5"> <div className="flex items-center justify-between"> <Label htmlFor={fieldId} className="font-mono text-xs"> invoice_number </Label> <AIGenerateButton label="Describe" tooltip="Write a description from the field name" onGenerate={() => "The supplier's invoice number, as printed on the invoice. Partial numbers are allowed with the supplier name." } onResult={setDescription} toastTitle="Description drafted" /> </div> <Textarea id={fieldId} value={description} onChange={(e) => setDescription(e.target.value)} /> </div> );}Your own trigger
useAIGenerate gives any button the same pending state, toast and undo. Here a ticket composer adds the summary as an internal note.
No internal notes yet.
import { useAIGenerate } from "@oration/canon/components/ai/ai-button";import { Button } from "@oration/canon/components/button";import { cn } from "@oration/canon/lib/utils";import { NotebookPenIcon } from "lucide-react";import * as React from "react";export function CustomTrigger() { const [notes, setNotes] = React.useState<{ id: number; text: string }[]>( [], ); const summarize = useAIGenerate({ onGenerate: () => "Halcyon was paid $412.50 short on INV-20931 because of the 2% early-pay discount. Sent the remittance advice.", onResult: (text) => setNotes((current) => [...current, { id: Date.now(), text }]), onUndo: () => setNotes((current) => current.slice(0, -1)), toastTitle: "Summary drafted as a note", simulateMs: 1100, }); return ( <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex min-h-20 flex-col gap-2 p-3"> {notes.length === 0 ? ( <p className="text-13 text-muted-foreground"> No internal notes yet. </p> ) : ( notes.map((note) => ( <p key={note.id} className="rounded-[10px] bg-muted/70 px-3 py-2 text-13 text-foreground" > {note.text} </p> )) )} </div> <div className="flex items-center gap-1.5 border-t border-border px-3 py-2"> <Button type="button" variant="ghost" size="sm" aria-busy={summarize.pending || undefined} onClick={() => { if (!summarize.pending) void summarize.run(); }} > <NotebookPenIcon data-icon="inline-start" aria-hidden="true" /> <span className={cn(summarize.pending && "text-shimmer")}> {summarize.pending ? "Summarizing…" : "Summarize as note"} </span> </Button> </div> </div> );}States#
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() { const states = [ { label: "Rest", className: "", disabled: false }, { label: "Hover", className: "bg-muted text-foreground dark:bg-muted/50", disabled: false, }, { label: "Focus visible", className: "border-ring ring-3 ring-ring/40", disabled: false, }, { label: "Disabled", className: "", disabled: true }, ]; const variants = ["ghost", "outline"] as const; return ( <div className="grid w-full min-w-0 grid-cols-[4.5rem_repeat(4,minmax(0,1fr))] items-center gap-x-2 gap-y-3 overflow-x-auto"> <span /> {states.map((state) => ( <span key={state.label} className="text-center text-xs text-muted-foreground" > {state.label} </span> ))} {variants.map((variant) => ( <div key={variant} className="contents"> <span className="text-13 text-muted-foreground capitalize"> {variant} </span> {states.map((state) => ( <div key={state.label} className="flex justify-center"> <AIGenerateButton variant={variant} disabled={state.disabled} onGenerate={() => "Draft"} onResult={() => {}} className={cn( "pointer-events-none", state.className, )} /> </div> ))} </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Ghost: Slate Meta text and icon on no fill. Outline adds the hairline and control shadow. |
| Hover | Ink text on Well Gray, over 150ms. |
| Focus visible | An indigo border and a 3px Focus Indigo ring at 40%. |
| Pressed | Scales to 0.96 while held, when motion is allowed. |
| Pending | The label shimmers as Generating…, the icon pulses, aria-busy is set and clicks are ignored. The button stays focusable. |
| Done | The field shows the draft and a toast confirms it, with Undo when onUndo is set. |
| Failed | An error toast: Couldn't generate a draft, Nothing changed. Try again in a moment., with Try again. The field is untouched. |
| Disabled | 50% opacity. Use it when there is nothing to work from, such as Improve with AI on an empty field. |
Behavior#
onGeneratemay return a string or a promise. A string resolves aftersimulateMs(900ms by default), so demos feel like real work.- While pending, a second click does nothing;
runis guarded so only one request is in flight. - On success it calls
onResult(text), then toasts. The toast has an Undo action only when you passonUndo. - Undo is yours to implement. Snapshot the old value in
onGenerate(or read it from the closure) and restore it inonUndo. - On failure (a thrown error or a rejected promise) it toasts the error with Try again, which runs the same request.
- If the component unmounts while pending, it skips the state update; the toast still fires.
tooltipwraps the button in a tooltip, for short labels such as Describe that need a sentence of explanation.
Do and don't#
Answers payment questions.
onUndo so the toast offers a way back to what was there.Answers payment questions.
pr-9 on a field that holds a corner action.Content#
- The label is a verb for what it writes: Draft reply, Improve with AI, Rewrite from the thread, Describe. Generate is fine in a label row, where the field names the object.
- The pending label repeats the verb in -ing form with one ellipsis character: Drafting…, Improving…, Rewriting…
- The toast title names the result in the past tense: Description drafted, Macro improved, Draft ready.
- The toast description says what it was written from, and asks for review when it matters: Written from the conversation and the payment record. Review before sending.
AIFieldActionlabels name the field: Draft a description from the title, Draft a greeting.
Accessibility#
- The button's name is its label. The field action's name is its
label(Generate with AI by default), repeated in its tooltip. - While pending the button uses
aria-disabledandaria-busyrather thandisabled, so focus stays on it. A hiddenrole="status"says Generating a draft. - The field action swaps its name and tooltip to Generating a draft and Generating… while pending.
- Results arrive in a toast, which is announced politely. Undo is a real button in it.
- The shimmer and pulse stop under reduced motion; the pending words still change.
- Both sizes are at least 24px. On touch-first forms prefer
size="sm"(28px).
| Keys | Action |
|---|---|
| Tab | Moves to the button or the field action. |
| Enter | Generates. Ignored while pending. |
| Space | Generates. Ignored while pending. |
Design tokens#
| Token | Used for |
|---|---|
--muted-foreground | Ghost label and icon at rest |
--foreground | Label and icon on hover |
--muted | Hover fill |
--ring | Focus border and ring |
text-shimmer | The pending label |
--animate-pulse-soft | The pending sparkles, 1.8s, behind motion-safe |
--radius-md | 8px corners at xs and sm |
API reference#
AIGenerateButton
The labelled sparkles button.
Other props spread onto Nothing. Only the props below are read; it renders a Button..
| Prop | Type | Default | Description |
|---|---|---|---|
onGenerateRequired | () => Promise<string> | string | No default | Produces the text. A string resolves after simulateMs. |
onResultRequired | (text: string) => void | No default | Receives the text. Put it in the field. |
onUndo | () => void | No default | Adds Undo to the success toast. Restore the old value here. |
toastTitle | string | "Draft added" | Success toast title. |
toastDescription | string | No default | Success toast description. |
simulateMs | number | 900 | How long a synchronous onGenerate pretends to work. |
label | string | "Generate" | Button label. |
pendingLabel | string | "Generating…" | Label while pending. |
size | "xs" | "sm" | "xs" | 24 or 28px. |
variant | "ghost" | "outline" | "ghost" | Ghost in label rows, outline below a field or in a sheet footer. |
tooltip | React.ReactNode | No default | Wraps the button in a tooltip. |
disabled | boolean | No default | Disables it, e.g. when there is nothing to improve. |
className | string | No default | Merged onto the button. |
AIFieldAction
The icon-only version for a field's corner. Takes the same onGenerate, onResult, onUndo, toastTitle, toastDescription and simulateMs as above.
Other props spread onto Nothing.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Generate with AI" | Accessible name and tooltip. |
position | "top-right" | "bottom-right" | "top-right" | Which corner of the relative wrapper it sits in. |
disabled | boolean | No default | Disables it. |
className | string | No default | Merged onto the button. |
useAIGenerate
The pending state, toasts and undo behind both components, for a trigger of your own. Takes the same options.
| Prop | Type | Default | Description |
|---|---|---|---|
optionsRequired | { onGenerate; onResult; onUndo?; toastTitle?; toastDescription?; simulateMs? } | No default | As for AIGenerateButton. |
returns | { pending: boolean; run: () => Promise<void> } | No default | Call run from your trigger; render from pending. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Some screens still hand-roll the pattern: Draft with Quill buttons in the compose email dialog and the inbox reply thread, and the contact center composer's tone rewrite, which re-implements the pending state, toast and undo.
Undo is left to each caller, so call sites snapshot the old value three different ways (closure, ref, parent callback).
The error toast's wording is fixed (Couldn't generate a draft) even for improve and rewrite actions, and can't be changed.
The success toast has no type, and the Undo toast times out like any other toast.
While pending the button still shows its hover fill, because it is only aria-disabled.