Skip to content

Variable chip

A {{variable}} token set in mono, showing whether the variable is defined.

Status
Beta
Level
Atom
Category
Editors
Adoption
Not used yet
import { VariableChip } from "@oration/canon/components/variable-chip";
packages/canon/src/components/variable-chip.tsx

Remittance email

Template

Hi supplier_name, we sent amount for invoice_number on payment_date (system variable). Your PO reference is po_number (undefined variable).

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 onClick to insert and a description that 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

Geist Mono appears only where a machine wrote the string. A variable name is one, so the chip is mono; the sentence around it stays in Geist Sans.

The Label-Beside-Color Rule

State never relies on color. Undefined variables add a warning icon, an inset warning ring, screen reader text and a tooltip; system variables add screen reader text and a tooltip.

Anatomy#

po_number (undefined variable)
Not defined yet. Add it to the variables list or it stays blank.
  1. Container. A 20px (or 18px at sm) pill with 8px corners, baseline-aligned so it sits in running text. A <span>, or a <button> with onClick.
  2. Warning icon. Undefined only: a 12px triangle, aria-hidden.
  3. Braces. {{ and }} at 55% opacity and aria-hidden, so the name reads first.
  4. Name. The variable name in Geist Mono, 12px (11px at sm), truncated if the chip is narrower.
  5. Tooltip. For system and undefined variables, or whenever description is 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.

supplier_namepayment_date (system variable)po_number (undefined variable)
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.

supplier_nameremit_tocaller_id (system variable)
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.

Insert a variable
Supplier
Payment

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#

RestHoverFocusSelected
Defined
amount
amount
amount
amount
System
amount (system variable)
amount (system variable)
amount (system variable)
amount (system variable)
Undefined
amount (undefined variable)
amount (undefined variable)
amount (undefined variable)
amount (undefined variable)
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>    );}
States
StateTreatment
Definedstate="defined", the default. Tag Gray fill, gray text. No tooltip unless description is set.
SystemTag Teal fill and text, with a tooltip: System variable, filled in at call time.
UndefinedA 15% warning tint, a 1px inset warning ring at 45%, a triangle icon and a tooltip saying it stays blank until defined.
HoverClickable chips only: brightness drops to 97% (rises to 110% in dark) over 150ms, with a pointer cursor.
Focus visibleClickable chips only: a 2px Focus Indigo ring at 60%.
Selectedselected draws the same 2px ring, for a chip selected inside an editor.

Behavior#

  • Without onClick the chip is a <span>; with it, a type="button" <button> that calls onClick with the mouse event.
  • A defined chip with no description renders bare. Every other chip is wrapped in a Tooltip, with description taking 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 inside max-w-full, so a long name in a narrow cell ends in an ellipsis.
  • align-baseline keeps it on the text baseline in paragraphs. Use sm in 12 and 13px text and the default in 14px text.

Do and don't#

Your PO reference is po_number (undefined variable).

Do. Mark variables that aren't defined as undefined, so the warning shows before the template is sent.

Your PO reference is po_number.

Don't. Render every variable as defined. A gray chip promises a value, and the supplier gets Hi , instead.
Do. Use the variable's machine name, lowercase with underscores, exactly as the template stores it.
Don't. Write a label in the chip. Spaces and capitals suggest it can be typed any way, and it won't match.

Content#

  • Names are lowercase snake case nouns: supplier_name, invoice_number, payment_date.
  • A description says 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 description that isn't also on the page.
  • The warning icon is aria-hidden; the screen reader text carries the state.
  • At 18px, sm chips are below the 24px hit target. Use the default size when chips are the main way to insert.
Keyboard interactions
KeysAction
TabMoves to a clickable chip and opens its tooltip.
EnterActivates a clickable chip.
SpaceActivates a clickable chip.
EscCloses the tooltip.

Design tokens#

Design tokens
TokenUsed for
--tag-gray, --tag-gray-fgDefined fill and text
--tag-teal, --tag-teal-fgSystem fill and text
--warning, --warning-foregroundUndefined: 15% fill, 45% inset ring, text (warning in dark)
--ring at 60%2px focus and selected ring
font-monoGeist Mono name and braces
rounded-md8px 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..

Props of VariableChip
PropTypeDefaultDescription
nameRequiredstringNo defaultThe variable name, without braces.
state"defined" | "undefined" | "system""defined"Custom, not defined yet, or filled in by the system.
descriptionReact.ReactNodeNo defaultOverrides the tooltip text. Also adds a tooltip to defined chips.
onClick(event: React.MouseEvent<HTMLElement>) => voidNo defaultMakes the chip a button, for insert palettes.
selectedbooleanfalseShows the 2px ring, for a chip selected in an editor.
size"sm" | "default""default"18px with 11px text, or 20px with 12px text.
classNamestringNo defaultMerged 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.