Variable chip
A {{variable}} token set in mono, showing whether the variable is defined.
Remittance email
Template
Won't be filled in:po_number (undefined variable)
import { VariableChip } from "@oration/canon/components/variable-chip";export function Hero() { return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-baseline justify-between gap-3"> <p className="text-sm font-medium text-foreground"> Remittance email </p> <p className="text-xs text-muted-foreground">Template</p> </div> <div className="rounded-[10px] bg-muted/70 p-3 text-13 leading-6 text-foreground"> Hi <VariableChip name="supplier_name" size="sm" />, we sent{" "} <VariableChip name="amount" size="sm" /> for{" "} <VariableChip name="invoice_number" size="sm" /> on{" "} <VariableChip name="payment_date" size="sm" state="system" />. Your PO reference is{" "} <VariableChip name="po_number" size="sm" state="undefined" />. </div> <p className="flex flex-wrap items-center gap-1.5 text-xs text-warning-foreground dark:text-warning"> Won't be filled in: <VariableChip name="po_number" size="sm" state="undefined" /> </p> </div> );}Usage#
Variable chip is a {{name}} token set in Geist Mono, for template variables in prompts, procedures, macros and message templates. Its fill says what kind of variable it is: gray for a custom variable, teal for a system variable filled in at call time, and a warning tint with an icon for one that isn't defined yet and will come out blank. The braces are drawn faintly and hidden from screen readers, so the name is what gets read. The mistake is showing an undefined variable as if it were fine; the warning state exists so a blank never reaches a supplier.
When to use
- Inline in a template preview, where a variable will be replaced: Hi {{supplier_name}}, we paid {{invoice_number}}.
- In a palette of insertable variables beside an editor, with
onClickto insert and adescriptionthat says where the value comes from. - In a variables list or sheet, at
sm, next to each variable's name and description. - With
state="undefined"to list variables a template uses that nobody has defined: Won't be filled in: {{po_number}}.
When not to use
- For editing a prompt with variables as atomic nodes. Use the editor, which renders chips for you. Use Prompt editor
- For a value such as a stage, tier or payment term. Use Tag
- For a keyboard shortcut. Use Kbd
- For a block of code, JSON or a full template. Use Code block
- For a list of key and value pairs a person edits. Use Key-value editor
Mono for machine strings
The Label-Beside-Color Rule
Anatomy#
- Container. A 20px (or 18px at
sm) pill with 8px corners, baseline-aligned so it sits in running text. A<span>, or a<button>withonClick. - Warning icon. Undefined only: a 12px triangle,
aria-hidden. - Braces.
{{and}}at 55% opacity andaria-hidden, so the name reads first. - Name. The variable name in Geist Mono, 12px (11px at
sm), truncated if the chip is narrower. - Tooltip. For system and undefined variables, or whenever
descriptionis set: what the variable is, or why it's blank.
Examples#
Defined, system and undefined
Gray for a custom variable, teal for one the system fills in at call time, and the warning tint with an icon for one that will come out blank. Hover the last two for their tooltips.
import { VariableChip } from "@oration/canon/components/variable-chip";export function Kinds() { return ( <> <VariableChip name="supplier_name" /> <VariableChip name="payment_date" state="system" /> <VariableChip name="po_number" state="undefined" /> </> );}Sizes
sm is 18px with 11px text for 12 and 13px copy; the default is 20px with 12px text for 14px copy. Both sit on the text baseline.
Small in 12 and 13px text: invoice_number
Default in 14px text: invoice_number
import { VariableChip } from "@oration/canon/components/variable-chip";export function Sizes() { return ( <div className="flex flex-col gap-4"> <p className="text-xs text-muted-foreground"> Small in 12 and 13px text:{" "} <VariableChip name="invoice_number" size="sm" /> </p> <p className="text-sm text-muted-foreground"> Default in 14px text: <VariableChip name="invoice_number" /> </p> </div> );}With a description
description replaces the default tooltip and adds one to defined chips, to say where the value comes from.
import { VariableChip } from "@oration/canon/components/variable-chip";export function WithDescription() { return ( <> <VariableChip name="supplier_name" description="The supplier's legal name from their W-9." /> <VariableChip name="remit_to" description="The remittance address on file, or the bank account for ACH." /> <VariableChip name="caller_id" state="system" description="The number the supplier called from, filled in when the call connects." /> </> );}Insert palette
With onClick the chip is a button. Here it inserts at the cursor, and any variable the message uses that isn't in the palette is listed as undefined.
Won't be filled in:po_number (undefined variable)
import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { VariableChip } from "@oration/canon/components/variable-chip";import * as React from "react";export function InsertPalette() { const groups = [ { group: "Supplier", keys: [ { key: "supplier_name", label: "Supplier name" }, { key: "remit_to", label: "Remit-to address" }, ], }, { group: "Payment", keys: [ { key: "invoice_number", label: "Invoice number" }, { key: "amount", label: "Amount paid" }, { key: "payment_date", label: "Payment date" }, ], }, ]; const known = groups.flatMap((group) => group.keys.map((k) => k.key)); const id = React.useId(); const ref = React.useRef<HTMLTextAreaElement>(null); const [text, setText] = React.useState( "Hi {{supplier_name}}, payment for {{invoice_number}} is on its way. PO {{po_number}}.", ); const used = Array.from( text.matchAll(/\{\{\s*([a-z0-9_]+)\s*\}\}/g), (m) => m[1] ?? "", ); const unknown = Array.from( new Set(used.filter((name) => !known.includes(name))), ); const insert = (key: string) => { const field = ref.current; const start = field?.selectionStart ?? text.length; const end = field?.selectionEnd ?? text.length; const token = `{{${key}}}`; setText(text.slice(0, start) + token + text.slice(end)); requestAnimationFrame(() => { field?.focus(); field?.setSelectionRange( start + token.length, start + token.length, ); }); }; return ( <div className="flex w-full max-w-lg flex-col gap-3"> <div className="flex flex-col gap-2"> <Label htmlFor={id}>Message</Label> <Textarea id={id} ref={ref} rows={3} value={text} onChange={(event) => setText(event.target.value)} className="font-mono text-xs" /> </div> <fieldset className="flex flex-col gap-2"> <legend className="mb-1 text-xs text-muted-foreground"> Insert a variable </legend> {groups.map((group) => ( <div key={group.group} className="flex flex-wrap items-center gap-1.5" > <span className="w-20 shrink-0 text-xs text-muted-foreground"> {group.group} </span> {group.keys.map((k) => ( <VariableChip key={k.key} name={k.key} size="sm" description={`${k.label}. Click to insert.`} onClick={() => insert(k.key)} /> ))} </div> ))} </fieldset> {unknown.length ? ( <p className="flex flex-wrap items-center gap-1.5 text-xs text-warning-foreground dark:text-warning"> Won't be filled in: {unknown.map((name) => ( <VariableChip key={name} name={name} size="sm" state="undefined" /> ))} </p> ) : ( <p className="text-xs text-muted-foreground"> Every variable in this message has a value. </p> )} </div> );}Variables list
At sm in a list, top-aligned with a one-line description, the way the procedure variables sheet lays them out.
- supplier_nameLegal name from the W-9
- open_balanceSum of unpaid invoices
- caller_id (system variable)Filled in when the call connects
- call_started_at (system variable)Time the call connected, in the workspace time zone
import { VariableChip } from "@oration/canon/components/variable-chip";export function VariablesList() { const variables = [ { name: "supplier_name", state: "defined" as const, description: "Legal name from the W-9", }, { name: "open_balance", state: "defined" as const, description: "Sum of unpaid invoices", }, { name: "caller_id", state: "system" as const, description: "Filled in when the call connects", }, { name: "call_started_at", state: "system" as const, description: "Time the call connected, in the workspace time zone", }, ]; return ( <div className="w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border"> <ul className="flex flex-col"> {variables.map((variable) => ( <li key={variable.name} className="flex items-start gap-3 border-b border-border px-3 py-2.5 last:border-b-0" > <VariableChip name={variable.name} state={variable.state} size="sm" className="mt-0.5" /> <span className="text-13 text-muted-foreground"> {variable.description} </span> </li> ))} </ul> </div> );}States#
import { VariableChip } from "@oration/canon/components/variable-chip";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() { const kinds = [ { state: "defined", label: "Defined" }, { state: "system", label: "System" }, { state: "undefined", label: "Undefined" }, ] as const; return ( <div className="grid w-full max-w-xl grid-cols-[6rem_repeat(4,minmax(0,1fr))] items-center gap-x-3 gap-y-4"> <span /> {columns.map((column) => ( <span key={column} className="text-center text-xs text-muted-foreground" > {column} </span> ))} {kinds.map((kind) => ( <div key={kind.state} className="contents"> <span className="text-13 text-muted-foreground"> {kind.label} </span> {columns.map((column) => ( <div key={column} className="flex justify-center"> <VariableChip name="amount" state={kind.state} selected={column === "Selected"} className={cn( "pointer-events-none", column === "Hover" && "brightness-[0.97] dark:brightness-110", column === "Focus" && "ring-2 ring-ring/60", )} /> </div> ))} </div> ))} </div> );}| State | Treatment |
|---|---|
| Defined | state="defined", the default. Tag Gray fill, gray text. No tooltip unless description is set. |
| System | Tag Teal fill and text, with a tooltip: System variable, filled in at call time. |
| Undefined | A 15% warning tint, a 1px inset warning ring at 45%, a triangle icon and a tooltip saying it stays blank until defined. |
| Hover | Clickable chips only: brightness drops to 97% (rises to 110% in dark) over 150ms, with a pointer cursor. |
| Focus visible | Clickable chips only: a 2px Focus Indigo ring at 60%. |
| Selected | selected draws the same 2px ring, for a chip selected inside an editor. |
Behavior#
- Without
onClickthe chip is a<span>; with it, atype="button"<button>that callsonClickwith the mouse event. - A defined chip with no
descriptionrenders bare. Every other chip is wrapped in a Tooltip, withdescriptiontaking the place of the default hint. - The tooltip opens on hover and on keyboard focus. On a non-clickable span it has no focus stop, so keyboard users get the state from the screen reader text only.
- The chip never wraps (
whitespace-nowrap) and truncates its name insidemax-w-full, so a long name in a narrow cell ends in an ellipsis. align-baselinekeeps it on the text baseline in paragraphs. Usesmin 12 and 13px text and the default in 14px text.
Do and don't#
Your PO reference is po_number (undefined variable).
Your PO reference is po_number.
Content#
- Names are lowercase snake case nouns: supplier_name, invoice_number, payment_date.
- A
descriptionsays where the value comes from: The supplier's legal name from their W-9. - In an insert palette, end the description with the action: Invoice number. Click to insert.
- Introduce a list of undefined chips with what will happen: Won't be filled in:.
Accessibility#
- The braces are
aria-hidden, so a screen reader reads supplier_name rather than left brace left brace. - System and undefined chips add screen reader text: (system variable) and (undefined variable).
- Clickable chips are real buttons with Enter and Space. Their name is the variable name, so give the palette a visible label such as Insert a variable.
- Tooltips on non-clickable chips can't be reached by keyboard. Don't put information in
descriptionthat isn't also on the page. - The warning icon is
aria-hidden; the screen reader text carries the state. - At 18px,
smchips are below the 24px hit target. Use the default size when chips are the main way to insert.
| Keys | Action |
|---|---|
| Tab | Moves to a clickable chip and opens its tooltip. |
| Enter | Activates a clickable chip. |
| Space | Activates a clickable chip. |
| Esc | Closes the tooltip. |
Design tokens#
| Token | Used for |
|---|---|
--tag-gray, --tag-gray-fg | Defined fill and text |
--tag-teal, --tag-teal-fg | System fill and text |
--warning, --warning-foreground | Undefined: 15% fill, 45% inset ring, text (warning in dark) |
--ring at 60% | 2px focus and selected ring |
font-mono | Geist Mono name and braces |
rounded-md | 8px corners |
API reference#
VariableChip
A {{name}} pill. Also exported: the VariableState type.
Other props spread onto Nothing. Renders a <span> or <button>; only the props below are used..
| Prop | Type | Default | Description |
|---|---|---|---|
nameRequired | string | No default | The variable name, without braces. |
state | "defined" | "undefined" | "system" | "defined" | Custom, not defined yet, or filled in by the system. |
description | React.ReactNode | No default | Overrides the tooltip text. Also adds a tooltip to defined chips. |
onClick | (event: React.MouseEvent<HTMLElement>) => void | No default | Makes the chip a button, for insert palettes. |
selected | boolean | false | Shows the 2px ring, for a chip selected in an editor. |
size | "sm" | "default" | "default" | 18px with 11px text, or 20px with 12px text. |
className | string | No default | Merged last onto the chip. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
System variables are Tag Teal, a categorical hue. The Option Hue Rule keeps those hues for select-option values and identity tints, not for a kind of thing.
Focus and selected draw a 2px ring at 60% with no border change. DESIGN.md gives controls a 3px ring at 40 to 50% plus an indigo border.
Hover is a brightness filter rather than a token step, so the hover color isn't in the palette.
No rest props are forwarded, so a clickable chip can't take an aria-label such as Insert invoice_number.