Using Canon
How designers and engineers pick up Canon: imports, composition, theming and the first screen.
Importing#
Canon ships as one workspace package, @oration/canon, imported file by file. There is no barrel to import from.
| Specifier | Resolves to | Holds |
|---|---|---|
@oration/canon/globals.css | packages/canon/src/styles/globals.css | Every token, utility and motion rule. The web app imports it once from app/globals.css. |
@oration/canon/components/* | packages/canon/src/components/*.tsx | Components, one file each. AI parts sit under ai/, voice under voice/, the prompt editor under prompt-editor/. |
@oration/canon/lib/* | packages/canon/src/lib/*.ts | Libraries: springs, utils (cn), demo-data, font-weight. |
@oration/canon/hooks/* | packages/canon/src/hooks/*.ts | Hooks such as use-mock-query, use-dirty-form and use-fluid-hover. |
import { Button } from "@oration/canon/components/button";import { ToolChip } from "@oration/canon/components/ai/tool-chip";import { VoiceOrb } from "@oration/canon/components/voice/orb";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { spring } from "@oration/canon/lib/springs";import { cn } from "@oration/canon/lib/utils";Import cn from Canon, not from the cn package
cn from @oration/canon/lib/utils knows that text-13 and text-2xs are font sizes. The bare cn package, which packages/canon components use internally, treats them as colors. When you override a component's text-sm through its className, write text-[13px].
Styling with tokens#
Style with semantic Tailwind tokens, never raw colors or pixel values. The names describe a role, so light and dark come from the same class.
| Token | Used for |
|---|---|
--background | bg-background. The White Plane: pages, table cells, the header. |
--card | bg-card with shadow-border and rounded-xl. Every raised surface. |
shadow-border | The hairline lift. Edge and lift in one shadow, never a CSS border plus a shadow. |
--muted | bg-muted/70 with rounded-[10px] for a tint well inside a card. |
--foreground | text-foreground. Graphite Ink for primary text. |
--muted-foreground | text-muted-foreground. Meta, column headers, resting icons. |
--border | border-border. Structural dividers only: table rules, header bottoms. |
--primary | bg-primary, through the default Button only. One per view. |
--ink-65 | bg-ink-65. The default fill for meters and progress. |
| Utility | Use for |
|---|---|
text-13 | Dense UI: rows, nav, toolbars, card copy. |
text-sm | Reading text and descriptions (14px). |
text-xs | Meta, captions and helper text (12px). |
tabular-nums | Every number that changes or gets compared. |
font-mono | Only machine strings: IDs, keys, codes, E.164 numbers. |
bg-card shadow-border, text-muted-foreground, bg-muted/70.bg-white border border-gray-200 text-gray-500. They break in dark mode and drift from the system.Custom utilities such as skeleton-shimmer, text-shimmer and shadow-ring are listed in Styles and utilities, and every token with its light and dark value is in Design tokens.
Your first screen#
A settings card for remittance emails, built in six steps from Canon parts. Change a field to see the save bar.
- Pick the page shape. Settings live on a document page with a narrow 48rem column; see Settings page.
- Group related settings in a
SettingsSectionwith a title and one sentence saying what the group controls. - Put the rows in a
SettingsGroup, oneSettingsRowper setting. PasshtmlForso the label names the control, and pointaria-describedbyatdescriptionId(id). Outside settings, pair a label and a control with Field. - Hold the values in
useDirtyForm, which keeps a saved baseline and counts what changed. - Add a
SaveBar. It appears when the form is dirty, counts the changes, saves on ⌘S and carries the view's one filled button. - Confirm the save with a toast.
Remittance settings
Remittance
What suppliers receive after each payment run.
import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function FirstScreen() { const id = React.useId(); const form = useDirtyForm( { replyTo: "remittance@cedarline.com", attachInvoices: true, notifyOnHold: false, }, { onSave: async () => { await new Promise((resolve) => setTimeout(resolve, 700)); toast.add({ type: "success", title: "Remittance settings saved", }); }, }, ); return ( <div className="w-full max-w-2xl text-left"> <SettingsSection title="Remittance" description="What suppliers receive after each payment run." > <SettingsGroup> <SettingsRow label="Reply-to address" htmlFor={`${id}-reply`} description="Supplier replies to remittance emails land here." > <Input id={`${id}-reply`} type="email" value={form.values.replyTo} onChange={(event) => form.set("replyTo", event.target.value) } aria-describedby={descriptionId(`${id}-reply`)} className="sm:w-64" /> </SettingsRow> <SettingsRow inline label="Attach invoice copies" htmlFor={`${id}-attach`} description="Adds each paid invoice as a PDF, up to 20 per email." > <Switch id={`${id}-attach`} checked={form.values.attachInvoices} onCheckedChange={(checked) => form.set("attachInvoices", checked) } aria-describedby={descriptionId(`${id}-attach`)} /> </SettingsRow> <SettingsRow inline label="Email me about holds" htmlFor={`${id}-holds`} description="A daily digest of invoices held for a missing W-9 or a PO mismatch." > <Switch id={`${id}-holds`} checked={form.values.notifyOnHold} onCheckedChange={(checked) => form.set("notifyOnHold", checked) } aria-describedby={descriptionId(`${id}-holds`)} /> </SettingsRow> </SettingsGroup> </SettingsSection> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} /> </div> );}Data and its states#
The suite runs on static mock data, read through useMockQuery so it behaves like a network call. DataState turns the query into a skeleton, an empty state, an error with Retry, or your content.
A list card that owns its states
Reload to see the skeleton, or reload with an error to see the error state and Retry. Keys are dotted and unique per component.
Payment runs this week
import { Button } from "@oration/canon/components/button";import { DataState } from "@oration/canon/components/data-state";import { SkeletonRows } from "@oration/canon/components/skeletons";import { StatusLabel } from "@oration/canon/components/status-dot";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function PaymentRuns() { const headingId = React.useId(); const [attempt, setAttempt] = React.useState(0); const [fail, setFail] = React.useState(false); const runs = [ { id: "RUN-0930", date: "Mon, Sep 28", invoices: 38, total: "$152,377.04", status: "Sent", tone: "success", }, { id: "RUN-0931", date: "Tue, Sep 29", invoices: 42, total: "$186,420.15", status: "Scheduled", tone: "neutral", }, { id: "RUN-0932", date: "Thu, Oct 1", invoices: 57, total: "$241,908.62", status: "Needs approval", tone: "warning", }, ] as const; const query = useMockQuery( `docs.using-canon.payment-runs.${attempt}`, () => runs, { delay: 1200, fail }, ); const reload = (withError: boolean) => { setFail(withError); setAttempt((n) => n + 1); }; return ( <div className="flex w-full max-w-lg flex-col gap-3 text-left"> <div className="flex flex-wrap gap-2"> <Button variant="outline" size="sm" onClick={() => reload(false)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Reload </Button> <Button variant="outline" size="sm" onClick={() => reload(true)} > Reload with an error </Button> </div> <section aria-labelledby={headingId} className="overflow-hidden rounded-xl bg-card shadow-border" > <h3 id={headingId} className="border-b border-border px-4 py-3 text-sm font-semibold" > Payment runs this week </h3> <DataState query={{ ...query, retry: () => reload(false) }} skeleton={ <SkeletonRows rows={3} avatar={false} className="px-2 py-1" /> } isEmpty={(list) => list.length < 1} empty={{ illustration: "schedule", title: "No payment runs this week", description: "Runs you schedule show here with their invoices and totals.", }} errorTitle="Couldn't load payment runs" > {(list) => ( <ul className="divide-y divide-border"> {list.map((run) => ( <li key={run.id} className="flex items-center gap-3 px-4 py-2.5" > <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {run.date} </p> <p className="flex gap-3 text-xs text-muted-foreground"> <span className="font-mono"> {run.id} </span> <span className="tabular-nums"> {run.invoices} invoices </span> </p> </div> <StatusLabel tone={run.tone} className="hidden text-muted-foreground sm:inline-flex" > {run.status} </StatusLabel> <span className="w-28 text-right text-13 font-medium tabular-nums"> {run.total} </span> </li> ))} </ul> )} </DataState> </section> </div> );}- Never gate a whole page on loading. Each card, table and panel owns its own states.
- Shape the skeleton like the final layout, using the presets in Skeleton where they fit.
- Empty states use an Illustration, a title, one sentence and the next action. Filtered-to-nothing says which filter hid everything.
- Errors say what failed in plain words and offer Retry. The user menu's demo controls can slow every query or fail the first attempt.
Toasts and feedback#
Every mutation gives feedback. Use toast.add({ title, description?, type?, actionProps? }) from @oration/canon/components/toast. Reversible actions offer Undo.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";export function Toasts() { return ( <div className="flex flex-wrap justify-center gap-2"> <Button variant="outline" onClick={() => toast.add({ type: "success", title: "W-9 request sent", description: "Halcyon Packaging gets a reminder every 3 days until they upload one.", }) } > Request W-9 </Button> <Button variant="outline" onClick={() => toast.add({ title: "Invoice archived", description: "INV-20438 from Orchard Street Foods.", actionProps: { children: "Undo", onClick: () => toast.add({ title: "Invoice restored" }), }, }) } > Archive invoice </Button> <Button variant="outline" onClick={() => toast.add({ type: "error", title: "Couldn't send remittance", description: "Northwind Freight's address bounced. Check the email on their supplier record.", }) } > Send remittance </Button> </div> );}The web app mounts one Toaster in its root layout, so call toast.add from anywhere. Toasts use the Base UI toast manager; Sonner is not installed. See Feedback.
Theming#
Light and dark come from one token set. The .dark class on the root element swaps every token; .light scopes a region back to light inside a dark page.
@custom-variant dark (&:is(.dark *):not(:is(.light, .light *)));- The theme preference is stored in localStorage as
oration-theme(light, dark or system) and applied before first paint, so there is no flash. - Because tokens are roles, a screen built from them needs no
dark:classes. Reach fordark:only to tune a value the token can't express. - Every example on this site has a preview theme switch. Check both before you ship.
Accessibility#
Base UI handles roles, focus and keyboard for the primitives. What it can't do is name things and choose the right element; that part is yours.
- Every control has a label, visible or screen-reader only. Icon-only buttons have an
aria-labeland a tooltip. - Focus stays visible. Don't remove the ring Canon draws.
- Hit areas are at least 24px, 32px on touch.
- Keyboard works everywhere a pointer does, and dialogs have titles.
- Status is never color alone; a label travels with every dot.
- Motion respects reduced motion: springs snap and shimmer stops.
The full checklist is in Accessibility.
Finding things#
Canon has a page for every component, pattern and template. Search reaches all of them.
| Keys | Action |
|---|---|
| ⌘K | Open search from anywhere in Canon. |
| / | Open search when focus isn't in a field. |
| Looking for | Go to |
|---|---|
| A component for a job | All components, filterable by level, category and status. |
| How to solve a recurring problem | Patterns, starting with Loading, empty and error. |
| The shape of a whole page | Templates, starting with Document page. |
| A token's value | Design tokens, read live from the stylesheet. |
| Everything as plain text | llms.txt and llms-full.txt. |
Working together#
How design and engineering share Canon, and where new code belongs.
With designers#
DESIGN.md at the repo root is the frozen visual spec. Canon explains it and shows it running; neither reinterprets it per app.
- When the code and DESIGN.md disagree, DESIGN.md wins. The component page records the drift under known gaps instead of quietly following the code.
- Each app gets its character from layout, density and interaction, not from new colors, fonts or radii.
- Proposals to change the system go through the lifecycle, not through a one-off screen.
In the app or in packages/canon#
Anything used in two places goes to packages/canon. Apps compose; they don't fork.
| What you're writing | Where it goes |
|---|---|
| A screen, a route or product-specific wiring | apps/web/src/app and apps/web/src/components/<area>. |
| Mock data and its lookup helpers | apps/web/src/lib/data, typed next to the data. |
| A control or layout used in a second place | packages/canon/src/components, with a Canon page. |
| A hook or helper with no product knowledge | packages/canon/src/hooks or packages/canon/src/lib. |
| A variant of an existing component | A prop on that component, not a copy in the app. |