Prompt bar
The composer: autosizing input, @ mentions, / commands, model, attachments, dictation and send.
import { type PromptAttachment, PromptBar } from "@oration/canon/components/ai/prompt-bar";import { toast } from "@oration/canon/components/toast";import { BotIcon, Building2Icon, FileTextIcon, TicketIcon } from "lucide-react";import * as React from "react";export function Hero() { const samples = [ { name: "remittance-PMT-58213.pdf", meta: "182 KB" }, { name: "w9-orchard-street.pdf", meta: "4.1 MB" }, { name: "payment-run-oct-2.csv", meta: "36 KB" }, ]; const [attachments, setAttachments] = React.useState<PromptAttachment[]>( [], ); const added = React.useRef(0); const attach = () => { const sample = samples[added.current % samples.length]; added.current += 1; if (!sample) return; const id = `att-${added.current}`; setAttachments((list) => [...list, { id, ...sample, uploading: true }]); window.setTimeout(() => { setAttachments((list) => list.map((file) => file.id === id ? { ...file, uploading: false } : file, ), ); }, 1100); }; return ( <div className="flex min-h-80 w-full max-w-xl flex-col justify-end gap-2 text-left"> <div className="flex h-6 w-fit items-center gap-1.5 rounded-md bg-muted/70 px-1.5 text-xs"> <Building2Icon aria-hidden="true" className="size-3.5 text-muted-foreground" /> <span className="text-muted-foreground">Looking at</span> <span className="font-medium text-foreground"> Northwind Freight </span> </div> <PromptBar label="Message Copilot" placeholder="Ask about Northwind Freight" mentions={[ { id: "sup_northwind", label: "Northwind Freight", description: "Supplier", icon: <Building2Icon aria-hidden="true" />, }, { id: "sup_halcyon", label: "Halcyon", description: "Supplier", icon: <Building2Icon aria-hidden="true" />, }, { id: "inv_20931", label: "INV-20931", description: "Invoice, Northwind Freight", icon: <FileTextIcon aria-hidden="true" />, }, { id: "ag_nora", label: "Nora", description: "Voice agent", icon: <BotIcon aria-hidden="true" />, }, { id: "tck_1184", label: "TCK-1184 Short payment on INV-20877", description: "Ticket", icon: <TicketIcon aria-hidden="true" />, }, ]} commands={[ { id: "summarize", label: "summarize", description: "Summarize this page or record", }, { id: "draft", label: "draft", description: "Draft a reply or an email to a supplier", }, { id: "create-ticket", label: "create-ticket", description: "Open a ticket with fields filled in", }, ]} models={[ { id: "auto", label: "Auto", description: "Picks a model per step", }, { id: "gpt-4-1", label: "GPT-4.1", description: "OpenAI, best for drafting", }, { id: "mistral-large", label: "Mistral Large", description: "Mistral, lowest cost", }, ]} attachments={attachments} onAttach={attach} onRemoveAttachment={(id) => setAttachments((list) => list.filter((file) => file.id !== id), ) } dictationText="Why did Northwind Freight call three times this week?" onSubmit={(text, details) => { const context = [ details.command ? `/${details.command.label}` : null, ...details.mentions.map( (mention) => `@${mention.label}`, ), ...details.attachments.map((file) => file.name), ].filter(Boolean); toast.add({ title: "Sent to Copilot", description: context.length ? `${text || "No text"}, with ${context.join(", ")}` : text, }); setAttachments([]); }} /> </div> );}Usage#
Prompt bar is the composer under a Copilot or agent conversation: an autosizing textarea with @ mentions that attach records, / commands, a model picker, attachments, dictation and a round Send that turns into Stop while a response runs. Mentions, the command and files become removable chips above the text and travel with the submit as structured details, so the answer is grounded in the right supplier or invoice. Send is the view's one filled button, which is why nothing else near the composer should be filled. The common mistake is wiring it up without mentions or a specific placeholder, which leaves people guessing what Copilot can see.
When to use
- As the composer of the Copilot panel, at
md, with mentions, commands, models and attachments. - As the single-line composer of a side panel at
sm, such as the Contact Center assist panel or a web-call chat with Nora. - For a one-shot natural-language request that starts a generation, such as describing a report to build.
- Whenever people should be able to pull a supplier, invoice, ticket or agent into the prompt with
@.
When not to use
- For a long prompt or agent instructions that people edit like a document, with variables. Use Prompt editor
- To generate the value of one form field, such as a tool description. Use AI generate button
- For a plain multi-line form field such as a note on a supplier. Use Textarea
- For searching or filtering a list. Use Search field
- For a command palette that runs actions without a conversation. Use Command menu
The One Filled Button Rule
The Quiet Indigo Rule
The Hairline-and-Lift Rule
rounded-xl bg-card shadow-border, with no CSS border. Focus adds a soft ring around the whole bar instead of a border on the textarea.Anatomy#
- Chips row. Appears once there is a command, a mention or a file: 24px Well Gray chips with an icon, a label, optional meta and a remove button.
- Textarea. Starts at one line and grows to
maxHeight(240px), then scrolls. 16px text below 768px so mobile browsers don't zoom, 14px above. - Attach. A 28px ghost icon button, shown when
onAttachis passed. Atsmit sits before the text. - Add context. Inserts
@at the caret and opens the mention menu.mdonly, whenmentionsis passed. - Model picker. An extra-small ghost button with the current model and a chevron, opening a radio menu.
mdonly, whenmodelsis passed. - Dictate. A ghost mic button that swaps to a check while listening. On by default.
- Send. A 28px round filled button with an up arrow, disabled until there is something to send. Becomes Stop, a filled square, while
isRunning.
Examples#
Sizes
md has a toolbar row for attachments, context, the model and dictation. sm is one line, for the Contact Center assist panel and agent test chats.
import { PromptBar } from "@oration/canon/components/ai/prompt-bar";import { toast } from "@oration/canon/components/toast";export function Sizes() { const send = (text: string) => toast.add({ title: "Message sent", description: text }); return ( <div className="flex w-full max-w-xl flex-col gap-6 text-left"> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> md, the Copilot panel </span> <PromptBar label="Message Copilot" placeholder="Ask about suppliers or payments" onAttach={() => toast.add({ title: "Choose files to attach" }) } models={[ { id: "auto", label: "Auto" }, { id: "gpt-4-1", label: "GPT-4.1" }, ]} dictationText="Which suppliers are in Friday's payment run?" onSubmit={send} /> </div> <div className="flex max-w-sm flex-col gap-2"> <span className="text-xs text-muted-foreground"> sm, a side panel </span> <PromptBar size="sm" label="Ask AI about this conversation" placeholder="Ask about policy, payments or this supplier" dictationText="What's the early-payment discount on Halcyon's terms?" onSubmit={send} /> </div> </div> );}Mentions and commands
Type @ anywhere to attach a record, or / as the first character for a command. Both become chips, and onSubmit receives them as structured details.
Send a message to see the details onSubmit receives.
import { PromptBar } from "@oration/canon/components/ai/prompt-bar";import { Building2Icon, FileTextIcon, TicketIcon } from "lucide-react";import * as React from "react";export function MentionsAndCommands() { const [last, setLast] = React.useState<string | null>(null); return ( <div className="flex min-h-80 w-full max-w-xl flex-col justify-end gap-3 text-left"> <pre className="min-h-16 rounded-[10px] bg-muted/70 px-3 py-2 font-mono text-xs whitespace-pre-wrap text-muted-foreground"> {last ?? "Send a message to see the details onSubmit receives."} </pre> <PromptBar label="Message Copilot" placeholder="Type @ to add a record, or / at the start for a command" dictation={false} mentions={[ { id: "sup_orchard", label: "Orchard Street", description: "Supplier", icon: <Building2Icon aria-hidden="true" />, }, { id: "sup_northwind", label: "Northwind Freight", description: "Supplier", icon: <Building2Icon aria-hidden="true" />, }, { id: "inv_os4471", label: "OS-4471", description: "Invoice, Orchard Street", icon: <FileTextIcon aria-hidden="true" />, }, { id: "tck_1184", label: "TCK-1184 Short payment on INV-20877", description: "Ticket", icon: <TicketIcon aria-hidden="true" />, }, ]} commands={[ { id: "summarize", label: "summarize", description: "Summarize this page or record", }, { id: "create-ticket", label: "create-ticket", description: "Open a ticket with fields filled in", }, ]} onSubmit={(text, details) => setLast( [ details.command ? `Command: ${details.command.id}` : null, details.mentions.length ? `Mentions: ${details.mentions.map((m) => m.id).join(", ")}` : null, `Text: ${text || "none"}`, ] .filter(Boolean) .join("\n"), ) } /> </div> );}Attachments
You own the list. Add an item with uploading while it uploads (Send stays disabled), flip it off when done, and clear the list on submit.
import { type PromptAttachment, PromptBar } from "@oration/canon/components/ai/prompt-bar";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Attachments() { const [files, setFiles] = React.useState<PromptAttachment[]>([ { id: "f1", name: "w9-orchard-street.pdf", meta: "4.1 MB" }, ]); const count = React.useRef(1); return ( <div className="w-full max-w-xl text-left"> <PromptBar label="Message Copilot" placeholder="Ask about these files" dictation={false} attachments={files} onAttach={() => { count.current += 1; const id = `f${count.current}`; setFiles((list) => [ ...list, { id, name: `remittance-PMT-5821${count.current}.pdf`, meta: "182 KB", uploading: true, }, ]); window.setTimeout(() => { setFiles((list) => list.map((file) => file.id === id ? { ...file, uploading: false } : file, ), ); }, 1500); }} onRemoveAttachment={(id) => setFiles((list) => list.filter((file) => file.id !== id)) } onSubmit={(_text, details) => { toast.add({ title: "Sent with attachments", description: details.attachments .map((file) => file.name) .join(", "), }); setFiles([]); }} /> </div> );}Send and stop
While isRunning is true, Send becomes Stop generating and calls onStop. Enter does nothing until the response finishes.
Send a message to start.
import { PromptBar } from "@oration/canon/components/ai/prompt-bar";import * as React from "react";export function RunningAndStop() { const [running, setRunning] = React.useState(false); const [status, setStatus] = React.useState("Send a message to start."); const timer = React.useRef<number | undefined>(undefined); React.useEffect(() => () => window.clearTimeout(timer.current), []); return ( <div className="flex w-full max-w-xl flex-col gap-2 text-left"> <PromptBar label="Message Copilot" placeholder="Ask about Friday's payment run" dictation={false} isRunning={running} onSubmit={(text) => { setRunning(true); setStatus(`Copilot is answering "${text}"`); timer.current = window.setTimeout(() => { setRunning(false); setStatus("Answer finished."); }, 5000); }} onStop={() => { window.clearTimeout(timer.current); setRunning(false); setStatus("You stopped the response."); }} /> <p role="status" className="px-1 text-xs text-muted-foreground"> {status} </p> </div> );}Dictation
The mic swaps the textarea for a listening row. The check inserts dictationText; the X or Escape cancels. Dictation is simulated in this prototype.
import { PromptBar } from "@oration/canon/components/ai/prompt-bar";import { toast } from "@oration/canon/components/toast";export function Dictation() { return ( <div className="flex w-full max-w-xl flex-col gap-6 text-left"> <PromptBar label="Message Copilot" placeholder="Press the mic to dictate" dictationText="Draft a reply to Aisha at Halcyon explaining the early-payment discount." onSubmit={(text) => toast.add({ title: "Message sent", description: text }) } /> <div className="max-w-sm"> <PromptBar size="sm" label="Message Nora" placeholder="Message Nora" dictationText="Hi, I'm calling about invoice INV-20931." onSubmit={(text) => toast.add({ title: "Sent to Nora", description: text }) } /> </div> </div> );}Controlled value
Pass value and onValueChange to fill the composer from outside, the way Copilot drops an approval's edit draft into it.
0 characters
import { PromptBar } from "@oration/canon/components/ai/prompt-bar";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ControlledValue() { const [value, setValue] = React.useState(""); const templates = [ "Draft a remittance reply to Halcyon", "Summarize Northwind Freight's open invoices", "Which suppliers are missing a W-9?", ]; return ( <div className="flex w-full max-w-xl flex-col gap-3 text-left"> <div className="flex flex-wrap gap-1.5"> {templates.map((template) => ( <Button key={template} type="button" variant="outline" size="sm" onClick={() => setValue(template)} > {template} </Button> ))} </div> <PromptBar label="Message Copilot" placeholder="Pick a starter or type your own" dictation={false} value={value} onValueChange={setValue} onSubmit={(text) => toast.add({ title: "Message sent", description: text }) } /> <p className="px-1 text-xs text-muted-foreground tabular-nums"> {value.length} characters </p> </div> );}States#
import { PromptBar } from "@oration/canon/components/ai/prompt-bar";import { toast } from "@oration/canon/components/toast";export function StatesMatrix() { const send = (text: string) => toast.add({ title: "Message sent", description: text }); return ( <div className="grid w-full gap-x-6 gap-y-5 text-left sm:grid-cols-2"> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Empty</span> <PromptBar size="sm" label="Empty example" placeholder="Ask about Northwind Freight" dictation={false} onSubmit={send} /> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Focused</span> <PromptBar size="sm" label="Focused example" placeholder="Ask about Northwind Freight" dictation={false} className="ring-3 ring-ring/20" onSubmit={send} /> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground"> Ready to send </span> <PromptBar size="sm" label="Ready example" defaultValue="Why is INV-20931 on hold?" dictation={false} onSubmit={send} /> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Uploading</span> <PromptBar size="sm" label="Uploading example" placeholder="Ask about this file" dictation={false} attachments={[ { id: "u1", name: "payment-run-oct-2.csv", uploading: true, }, ]} onSubmit={send} /> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Running</span> <PromptBar size="sm" label="Running example" placeholder="Ask a follow-up" dictation={false} isRunning onStop={() => toast.add({ title: "Response stopped" })} onSubmit={send} /> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Disabled</span> <PromptBar size="sm" label="Disabled example" placeholder="Copilot is off for this workspace" dictation={false} disabled onSubmit={send} /> </div> </div> );}| State | Treatment |
|---|---|
| Empty | Placeholder in Slate Meta, Send disabled. |
| Focused | Keyboard focus in the textarea draws a 3px Focus Indigo ring at 20% around the whole bar. |
| Ready | Send enables once there is text, a command or an attachment, and no file is still uploading. |
| Menu open | Typing @ (or / at the very start) opens a popover above the bar (menuSide="top") labelled Add context or Commands, with the first match highlighted. No matches shows No matching records or No matching commands. |
| With chips | Picked mentions, the command and attachments sit in a row above the text. The command can only be picked once. |
| Uploading | An attachment with uploading shimmers its name, shows Uploading and keeps Send disabled. |
| Listening | The textarea is replaced by a row: an indigo pulsing dot and Listening, an ink waveform, an elapsed clock and a cancel button. The mic becomes a check that inserts the text. |
| Running | Send becomes Stop generating, enabled when onStop is passed. Pressing Enter does nothing until it finishes. |
| Disabled | The whole bar dims to 60%. The textarea, Attach, Add context, Dictate and Send are disabled; the model picker is not (see known gaps). |
Behavior#
@opens the mention menu anywhere after a space or at the start;/opens commands only as the first character, and only until a command is picked. Both menus filter by label or id as you type.- The mention menu shows up to eight matches and leaves out records already picked. Commands aren't capped.
- Picking an item removes the typed trigger and query from the text and adds a chip. Focus stays in the textarea throughout; the menu is driven with
aria-activedescendant. - Enter sends, Shift+Enter adds a line. Enter or Tab picks the highlighted menu item instead while a menu is open. IME composition is respected.
- Backspace with the caret at the very start removes the last mention chip, then the command.
- On submit,
onSubmit(text, { mentions, command, model, attachments })receives trimmed text, then the bar clears its text, mentions and command. Attachments belong to you: clear them inonSubmit. valuewithonValueChange(or its aliasonChange) makes the text controlled, which is how Copilot drops an edit draft into the composer.modelwithonModelChangecontrols the model the same way.- Dictation is simulated: the mic shows the listening row, and the check inserts
dictationTextafter the current text. Escape or the X cancels without inserting. onAttachis only a callback; the bar has no file input. You addPromptAttachmentitems, flipuploadingoff when they finish, and remove them inonRemoveAttachment.- The menus open without animation because typing opens them. The model picker is a dropdown menu and animates like one.
Do and don't#
Content#
- Placeholder: Ask about plus what's in context, such as Ask about Northwind Freight or Ask about policy, payments or this supplier. Default Ask anything.
labelis the textarea's accessible name and should name the recipient: Message Copilot, Message Nora. Default Message.- Mention labels are the record's name as people see it in the CRM: Northwind Freight, INV-20931, TCK-1184 Short payment on INV-20877. Use
descriptionfor the kind: Supplier, Invoice, Voice agent. - Command labels are the bare command, such as summarize or create-ticket. The chip and the welcome copy add the slash.
- Command descriptions say what comes back: Open a ticket with fields filled in.
- Attachment
metais the size, 182 KB. While uploading it reads Uploading.
Accessibility#
- The textarea is named by
labeland described by a hidden hint that lists what works: Type @ to add context. Type / at the start for commands. Enter sends, Shift+Enter adds a new line. - While a menu is open the textarea sets
aria-controlsandaria-activedescendant; the menu is arole="listbox"of options witharia-selected. - Every icon button has a name and a tooltip: Attach files, Add context (with an @ key hint), Dictate, Insert dictation, Cancel dictation, Send, Stop generating. The model picker reads Model: Auto.
- Chip remove buttons are named Remove Northwind Freight, and focus returns to the textarea after a removal.
- Dictate sets
aria-pressedwhile listening, and Listening is arole="status". - Text is 16px below 768px so iOS doesn't zoom on focus.
| Keys | Action |
|---|---|
| @ | Opens the mention menu at the caret. |
| / | As the first character, opens the command menu. |
| ↓↑ | Moves the highlight in an open menu, wrapping. |
| Enter | Picks the highlighted item, or sends when no menu is open. |
| Tab | Picks the highlighted item while a menu is open. |
| ShiftEnter | Adds a new line. |
| Esc | Closes the menu, or cancels dictation. |
| Backspace | At the start of the text, removes the last chip. |
Design tokens#
| Token | Used for |
|---|---|
--card | Bar surface |
shadow-border | Hairline lift around the bar |
--ring | Focus ring at 20% around the bar |
--muted | Chips, menu highlight |
--muted-foreground | Placeholder, toolbar icons, chip icons and meta |
--primary | Send and Stop fill, the listening dot |
--popover | Mention and command menu |
shadow-popover | Menu lift |
--radius-xl | 12px bar corners |
text-shimmer | Uploading attachment name |
API reference#
PromptBar
The composer, rendered as a <form>. Also exported: the PromptMention, PromptCommand, PromptAttachment, PromptSubmitDetails and PromptModel types.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | No default | Controlled text. |
defaultValue | string | "" | Initial text when uncontrolled. |
onValueChange | (value: string) => void | No default | Called on every text change. onChange is an alias. |
onSubmit | (text: string, details: PromptSubmitDetails) => void | No default | Trimmed text plus { mentions, command, model, attachments }. |
onStop | () => void | No default | Enables Stop while running. |
isRunning | boolean | false | Turns Send into Stop and blocks sending. |
placeholder | string | "Ask anything" | Say what Copilot can see. |
label | string | "Message" | Accessible name of the textarea. |
mentions | { id: string; label: string; description?: string; meta?: string; icon?: React.ReactNode }[] | No default | Enables @ and the Add context button. |
commands | { id: string; label: string; description?: string; meta?: string; icon?: React.ReactNode }[] | No default | Enables / at the start. |
models | { id: string; label: string; description?: string }[] | No default | Shows the model picker at md. Defaults to the first. |
model | string | No default | Controlled model id. |
onModelChange | (id: string) => void | No default | Called when the model changes. |
attachments | { id: string; name: string; meta?: string; uploading?: boolean; icon?: React.ReactNode }[] | No default | Files to show as chips. You own this list. |
onAttach | () => void | No default | Shows Attach files. |
onRemoveAttachment | (id: string) => void | No default | Shows a remove button on each file chip. |
dictation | boolean | true | Shows the mic. |
dictationText | string | "Draft a follow-up to Priya about the revised pricing." | What the simulated dictation inserts. |
size | "sm" | "md" | "md" | md has a toolbar row; sm is one line for side panels. |
menuSide | "top" | "bottom" | "top" | Where the menus and model picker open. |
maxHeight | number | 240 | Pixels the textarea grows to before scrolling. |
autoFocus | boolean | No default | Focuses the textarea on mount. |
disabled | boolean | No default | Dims the bar and disables the textarea and its buttons. |
className | string | No default | Merged onto the form. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
dictationText defaults to product copy (Draft a follow-up to Priya about the revised pricing.), and dictation defaults to on, so any prompt bar that forgets either shows a mic that inserts that sentence.
Dictation and attachments are simulated: there is no speech recognition and no file input.
Focus draws a 3px ring at 20% and no indigo border, softer than the field focus in DESIGN.md (indigo border plus a 3px ring at 50%). Disabled dims to 60% rather than the system's 50%.
The app's Copilot command labels start with / (/summarize), and the chip adds a slash icon, so the chip reads as two slashes.
The menus are positioned inside the form, not portaled. A parent with overflow-hidden clips them.
At sm there is no model picker and no Add context button; mentions still work by typing @.
disabled doesn't reach the model picker, which stays clickable on a dimmed bar.