Skip to content

AI generate button

The sparkles button beside any descriptive field: it drafts text, shimmers while working and offers undo.

Status
Stable
Category
AI
Adoption
Not used yet
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";
packages/canon/src/components/ai/ai-button.tsx
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 useAIGenerate when 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

Generate is an assist. It is ghost (or outline) at 24 or 28px, so the form's Save stays the one filled button.

The Quiet Indigo Rule

The sparkles icon is Slate Meta and turns ink on hover. It is never indigo, never a gradient, and never set in a colored tile.

AI everywhere, without noise

One generate affordance per descriptive field, placed the same way across the suite: in the label row, or in the field's corner. Every result can be undone.

Anatomy#

  1. Sparkles icon. 12px at xs, 14px at sm, with the inline-start padding. It pulses softly while pending when motion is allowed.
  2. Label. Generate by default, or a verb for the job. Replaced by Generating… in a shimmer while pending.
  3. 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.
  4. Reserved padding. pr-9 on the field, so typed text never runs under the field action.
  5. Undo toast. After the text lands: a toast titled Draft added (or your toastTitle) with an Undo action when onUndo is 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.

Simulate an outage
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#

RestHoverFocus visibleDisabled
ghost
outline
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>    );}
States
StateTreatment
RestGhost: Slate Meta text and icon on no fill. Outline adds the hairline and control shadow.
HoverInk text on Well Gray, over 150ms.
Focus visibleAn indigo border and a 3px Focus Indigo ring at 40%.
PressedScales to 0.96 while held, when motion is allowed.
PendingThe label shimmers as Generating…, the icon pulses, aria-busy is set and clicks are ignored. The button stays focusable.
DoneThe field shows the draft and a toast confirms it, with Undo when onUndo is set.
FailedAn error toast: Couldn't generate a draft, Nothing changed. Try again in a moment., with Try again. The field is untouched.
Disabled50% opacity. Use it when there is nothing to work from, such as Improve with AI on an empty field.

Behavior#

  • onGenerate may return a string or a promise. A string resolves after simulateMs (900ms by default), so demos feel like real work.
  • While pending, a second click does nothing; run is 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 pass onUndo.
  • Undo is yours to implement. Snapshot the old value in onGenerate (or read it from the closure) and restore it in onUndo.
  • 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.
  • tooltip wraps the button in a tooltip, for short labels such as Describe that need a sentence of explanation.

Do and don't#

Description
Do. Put a ghost Generate in the label row, beside the field it fills. Save stays the one filled button.
Description
Don't. Make Generate with AI a filled button. It competes with Save and reads as the reason the page exists.

Answers payment questions.

Do. Pass onUndo so the toast offers a way back to what was there.

Answers payment questions.

Don't. Overwrite the field with no undo. Someone's own words are gone after one click.
Do. Reserve pr-9 on a field that holds a corner action.
Don't. Drop the field action on a field without padding. Text runs under the icon.

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.
  • AIFieldAction labels 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-disabled and aria-busy rather than disabled, so focus stays on it. A hidden role="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).
Keyboard interactions
KeysAction
TabMoves to the button or the field action.
EnterGenerates. Ignored while pending.
SpaceGenerates. Ignored while pending.

Design tokens#

Design tokens
TokenUsed for
--muted-foregroundGhost label and icon at rest
--foregroundLabel and icon on hover
--mutedHover fill
--ringFocus border and ring
text-shimmerThe pending label
--animate-pulse-softThe pending sparkles, 1.8s, behind motion-safe
--radius-md8px 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..

Props of AIGenerateButton
PropTypeDefaultDescription
onGenerateRequired() => Promise<string> | stringNo defaultProduces the text. A string resolves after simulateMs.
onResultRequired(text: string) => voidNo defaultReceives the text. Put it in the field.
onUndo() => voidNo defaultAdds Undo to the success toast. Restore the old value here.
toastTitlestring"Draft added"Success toast title.
toastDescriptionstringNo defaultSuccess toast description.
simulateMsnumber900How long a synchronous onGenerate pretends to work.
labelstring"Generate"Button label.
pendingLabelstring"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.
tooltipReact.ReactNodeNo defaultWraps the button in a tooltip.
disabledbooleanNo defaultDisables it, e.g. when there is nothing to improve.
classNamestringNo defaultMerged 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.

Props of AIFieldAction
PropTypeDefaultDescription
labelstring"Generate with AI"Accessible name and tooltip.
position"top-right" | "bottom-right""top-right"Which corner of the relative wrapper it sits in.
disabledbooleanNo defaultDisables it.
classNamestringNo defaultMerged onto the button.

useAIGenerate

The pending state, toasts and undo behind both components, for a trigger of your own. Takes the same options.

Props of useAIGenerate
PropTypeDefaultDescription
optionsRequired{ onGenerate; onResult; onUndo?; toastTitle?; toastDescription?; simulateMs? }No defaultAs for AIGenerateButton.
returns{ pending: boolean; run: () => Promise<void> }No defaultCall 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.