Styles and utilities
The custom Tailwind utilities, variants and recipes Canon adds on top of the defaults.
Two type sizes#
Canon adds 13px and 11px to Tailwind's scale. Everything else is a Tailwind default.
text-1313px on 20pxNorthwind Freight paid $18,240.00 on Sep 28Dense UI: table cells, nav, toolbars, list rows, card copy.text-2xs11px on 16pxCredits reset Oct 1Footnotes only: a meter's target label, a credits reset date, sidebar counts.@theme { --text-2xs: 0.6875rem; --text-2xs--line-height: 1rem; --text-13: 0.8125rem; --text-13--line-height: 1.25rem;}Both carry their own line height, so text-13 is 13px on a 20px line without a leading-* class. The ramp and where each size goes are on Typography; the merging note below matters every time you pass one to a component.
Shadow utilities#
Seven Tailwind shadows mapped from the elevation stacks, plus shadow-ring. Each tile below wears its utility.
shadow-borderCards, panels, kanban cards, flow nodes, the sidebar search field.
shadow-border-hoverHover on interactive cards, the expanded approval, a card being dragged.
shadow-popoverThe floating bulk action bar.
shadow-xsOutline buttons.
shadow-smNot named in DESIGN.md. Prefer shadow-border or shadow-xs.
shadow-mdMenus, popovers, selects and comboboxes, with a 1px ink ring.
shadow-lgDialogs and sheets, with the same ring.
shadow-ringAn @utility that writes the hairline lift to box-shadow directly.
Rest and hover
shadow-border at rest, hover:shadow-border-hover on hover over 150ms on the house ease-out.
import * as React from "react";export function ShadowHover() { const [lifted, setLifted] = React.useState<string | null>(null); const suppliers = [ { name: "Northwind Freight", open: "$24,516.00" }, { name: "Halcyon", open: "$12,940.50" }, ]; return ( <div className="grid w-full max-w-md gap-3 sm:grid-cols-2"> {suppliers.map((supplier) => ( <button key={supplier.name} type="button" onClick={() => setLifted(supplier.name)} className="flex flex-col items-start gap-1 rounded-xl bg-card p-4 text-left shadow-border transition-shadow duration-150 ease-out outline-none hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40" > <span className="text-sm font-semibold text-foreground"> {supplier.name} </span> <span className="text-xs text-muted-foreground"> Open invoices{" "} <span className="text-foreground tabular-nums"> {supplier.open} </span> </span> <span className="pt-1 text-xs text-muted-foreground" aria-live="polite" > {lifted === supplier.name ? "Opened" : "Hover to lift"} </span> </button> ))} </div> );}@theme inline { --shadow-xs: var(--elevation-xs); --shadow-sm: var(--elevation-sm); --shadow-md: var(--elevation-md); --shadow-lg: var(--elevation-lg); --shadow-border: var(--elevation-border); --shadow-border-hover: var(--elevation-border-hover); --shadow-popover: var(--elevation-popover);}@utility shadow-ring { box-shadow: var(--elevation-border);}shadow-borderruns through Tailwind's shadow stack, so it combines withring-*on the same element.shadow-ringreplacesbox-shadowoutright and drops any ring. Nothing in the product usesshadow-ring; reach forshadow-border.- Never pair a CSS
borderwith ashadow-*on a raised surface. The hairline is already in the shadow. See Elevation. - Menus and dialogs add their 1px ring in the component (
ring-1 ring-foreground/10). Don't add another.
Ink fills#
bg-ink-80 to bg-ink-15: Graphite Ink at five fixed alphas, defined as Tailwind colors so they invert with the theme.
bg-ink-80bg-ink-65bg-ink-45bg-ink-25bg-ink-15Like any Tailwind color they work with every color utility: text-ink-65, border-ink-25, fill-ink-45. They are for magnitudes, not for text; text uses Graphite Ink, Slate Meta or Faint Slate. Which step goes where is on Color.
Easing, duration and animation#
Four curves, one extra duration and four keyframe animations. Springs for JavaScript motion live in @oration/canon/lib/springs.
The four curves
Run the dots to compare the curves. 320ms is the longest any state change runs; the slower setting is for study.
ease-outHovers, entrances, disclosureease-in-outThe soft pulse on live dotsease-drawerSheetsease-standardDefined, not used yet
import { Button } from "@oration/canon/components/button";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { cn } from "@oration/canon/lib/utils";import { PlayIcon } from "lucide-react";import * as React from "react";export function Easings() { const curves = [ { name: "ease-out", className: "ease-out", use: "Hovers, entrances, disclosure", }, { name: "ease-in-out", className: "ease-in-out", use: "The soft pulse on live dots", }, { name: "ease-drawer", className: "ease-drawer", use: "Sheets" }, { name: "ease-standard", className: "ease-standard", use: "Defined, not used yet", }, ]; const [at, setAt] = React.useState(false); const [speed, setSpeed] = React.useState<"real" | "slow">("slow"); const duration = speed === "real" ? 320 : 1280; return ( <div className="flex w-full max-w-lg flex-col gap-4"> <div className="flex items-center justify-between gap-3"> <SegmentedControl<"real" | "slow"> label="Speed" value={speed} onValueChange={setSpeed} options={[ { value: "real", label: "320ms" }, { value: "slow", label: "4x slower" }, ]} /> <Button type="button" variant="outline" size="sm" onClick={() => setAt((value) => !value)} > <PlayIcon data-icon="inline-start" aria-hidden="true" /> Run </Button> </div> <ul className="flex flex-col gap-3"> {curves.map((curve) => ( <li key={curve.name} className="flex flex-col gap-1.5"> <div className="flex items-baseline justify-between gap-3 text-13"> <code className="font-mono text-xs text-foreground"> {curve.name} </code> <span className="text-xs text-muted-foreground"> {curve.use} </span> </div> <div className="relative h-5 rounded-full bg-muted"> <span className={cn( "absolute inset-0.5 transition-transform motion-reduce:transition-none", curve.className, )} style={{ transitionDuration: `${duration}ms`, transform: at ? "translateX(calc(100% - 1rem))" : "none", }} > <span className="block size-4 rounded-full bg-ink-65" /> </span> </div> </li> ))} </ul> </div> );}The four animations and two shimmers
Shimmer for queued and running work, a soft pulse for live state, marching dashes for a running edge, a blinking caret in code inputs. Under reduced motion the shimmers stop.
skeleton-shimmertext-shimmerFinding email for Wen Zhou…animate-pulse-softNora is on a callanimate-dashanimate-caret-blinkimport { Button } from "@oration/canon/components/button";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Animations() { const [running, setRunning] = React.useState(true); return ( <div className="flex w-full max-w-lg flex-col gap-4"> <div className="grid gap-3 sm:grid-cols-2"> <div className="flex flex-col gap-2 rounded-[10px] bg-muted/70 p-3"> <code className="font-mono text-xs text-muted-foreground"> skeleton-shimmer </code> <div className="flex flex-col gap-1.5" aria-hidden="true"> <span className={cn( "h-2 w-4/5 rounded-full", running ? "skeleton-shimmer" : "bg-muted", )} /> <span className={cn( "h-2 w-3/5 rounded-full", running ? "skeleton-shimmer" : "bg-muted", )} /> </div> </div> <div className="flex flex-col gap-2 rounded-[10px] bg-muted/70 p-3"> <code className="font-mono text-xs text-muted-foreground"> text-shimmer </code> <span className={cn( "text-13", running ? "text-shimmer" : "text-muted-foreground", )} > Finding email for Wen Zhou… </span> </div> <div className="flex flex-col gap-2 rounded-[10px] bg-muted/70 p-3"> <code className="font-mono text-xs text-muted-foreground"> animate-pulse-soft </code> <span className="flex items-center gap-1.5 text-13 text-foreground"> <span aria-hidden="true" className={cn( "size-2 rounded-full bg-primary", running && "motion-safe:animate-pulse-soft", )} /> Nora is on a call </span> </div> <div className="flex flex-col gap-2 rounded-[10px] bg-muted/70 p-3"> <code className="font-mono text-xs text-muted-foreground"> animate-dash </code> <svg viewBox="0 0 160 20" className="h-5 w-full" aria-hidden="true" > <path d="M4 10 H156" fill="none" className={cn( "stroke-primary", running && "motion-safe:animate-dash", )} strokeWidth={1.75} strokeDasharray="6 4" strokeLinecap="round" /> </svg> </div> <div className="flex flex-col gap-2 rounded-[10px] bg-muted/70 p-3 sm:col-span-2"> <code className="font-mono text-xs text-muted-foreground"> animate-caret-blink </code> <div className="flex gap-1.5" aria-hidden="true"> {["4", "8", "2", ""].map((digit, index) => ( <span // biome-ignore lint/suspicious/noArrayIndexKey: fixed slots key={index} className={cn( "relative flex size-8 items-center justify-center rounded-lg border bg-background text-sm font-medium text-foreground tabular-nums", digit ? "border-input" : "border-ring ring-3 ring-ring/50", )} > {digit} {!digit ? ( <span className={cn( "h-4 w-px bg-foreground", running && "animate-caret-blink", )} /> ) : null} </span> ))} </div> </div> </div> <Button type="button" variant="outline" size="sm" className="self-start" onClick={() => setRunning((value) => !value)} > {running ? "Stop animations" : "Start animations"} </Button> </div> );}@theme { --ease-out: cubic-bezier(0.23, 1, 0.32, 1); --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); --ease-standard: cubic-bezier(0.2, 0, 0, 1); --duration-80: 80ms; --animate-shimmer: shimmer 1.6s linear infinite; --animate-pulse-soft: pulse-soft 1.8s var(--ease-in-out) infinite; --animate-dash: dash 0.9s linear infinite; --animate-caret-blink: caret-blink 1.1s ease-out infinite;}:root,.light { --duration-fast: 80ms; --duration-moderate: 160ms; --duration-slow: 240ms;}@utility text-shimmer { background: linear-gradient( 90deg, var(--muted-foreground) 0%, var(--muted-foreground) 40%, var(--foreground) 50%, var(--muted-foreground) 60%, var(--muted-foreground) 100% ) 0 0 / 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: var(--animate-shimmer);}@utility skeleton-shimmer { background: linear-gradient( 90deg, var(--muted) 0%, color-mix(in oklch, var(--muted), var(--foreground) 5%) 50%, var(--muted) 100% ) 0 0 / 200% 100%; animation: var(--animate-shimmer);}| Utility | Value | Use |
|---|---|---|
ease-out | cubic-bezier(0.23, 1, 0.32, 1) | The house curve. Replaces Tailwind's default ease-out, so the plain class is already Canon's. |
ease-in-out | cubic-bezier(0.77, 0, 0.175, 1) | Also replaces Tailwind's default. Drives animate-pulse-soft. |
ease-drawer | cubic-bezier(0.32, 0.72, 0, 1) | Sheets, over 320ms in and 200ms out. |
ease-standard | cubic-bezier(0.2, 0, 0, 1) | Defined but not used, and not in DESIGN.md. Don't use it. |
duration-80 | 80ms | The fastest color change: the switch track and its label. |
animate-shimmer | shimmer 1.6s linear infinite | Used by text-shimmer and skeleton-shimmer. |
animate-pulse-soft | pulse-soft 1.8s var(--ease-in-out) infinite | Live dots and loaders. Wrap it in motion-safe:. |
animate-dash | dash 0.9s linear infinite | The running edge on the flow canvas. Needs a dash pattern that sums to 10, such as 6 4. |
animate-caret-blink | caret-blink 1.1s ease-out infinite | The fake caret in OTP slots and streaming text. |
text-shimmer | A Slate Meta to Graphite Ink sweep clipped to the text | A running label: “Finding email…”. The only gradient-clipped text in Canon. |
skeleton-shimmer | Well Gray with a 5% ink sweep | Skeleton bars and queued enrichment cells. |
--duration-fast, --duration-moderate and --duration-slow (80, 160 and 240ms) are defined in :root but nothing reads them, and DESIGN.md gives hovers 150ms, popovers 160ms and dialogs 200ms. Write duration-150, duration-200 and friends, and follow Motion for which to use.
Overflow fade and hidden scrollbars#
data-overflow-fade masks the clipped side of a horizontal row over 2rem. no-scrollbar hides the bar on rows that scroll by touch, wheel or keyboard.
A scrolling view row
useOverflowFade sets data-overflow-start and data-overflow-end while content is clipped and keeps the selected view in sight.
Scroll the row. The clipped side fades over 2rem, and only while something is clipped.
import { useOverflowFade } from "@oration/canon/hooks/use-overflow-fade";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function OverflowFade() { const views = [ "All invoices", "Awaiting approval", "Due this week", "On hold", "Early-pay discounts", "Missing W-9", "Paid in September", "Disputed", ]; const [active, setActive] = React.useState("Due this week"); const ref = useOverflowFade<HTMLDivElement>(); return ( <div className="flex w-full max-w-sm flex-col gap-2 rounded-xl bg-card p-2 shadow-border"> <div ref={ref} data-overflow-fade role="tablist" aria-label="Invoice views" className="no-scrollbar flex gap-1 overflow-x-auto" > {views.map((view) => ( <button key={view} type="button" role="tab" aria-selected={view === active} onClick={() => setActive(view)} className={cn( "h-7 shrink-0 rounded-md px-2.5 text-13 whitespace-nowrap outline-none transition-colors duration-150 ease-out focus-visible:ring-3 focus-visible:ring-ring/40", view === active ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:text-foreground", )} > {view} </button> ))} </div> <p className="px-2 pb-1 text-xs text-muted-foreground"> Scroll the row. The clipped side fades over 2rem, and only while something is clipped. </p> </div> );}[data-overflow-fade][data-overflow-end] { mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent);}[data-overflow-fade][data-overflow-start] { mask-image: linear-gradient(to left, #000 calc(100% - 2rem), transparent);}[data-overflow-fade][data-overflow-start][data-overflow-end] { mask-image: linear-gradient( to right, transparent, #000 2rem, #000 calc(100% - 2rem), transparent );}@utility no-scrollbar { scrollbar-width: none; &::-webkit-scrollbar { display: none; }}The hook and its rules are documented on Overflow fade. Tabs with scrollable already use it. Use no-scrollbar only where the fade shows that more is there.
Motion by data-slot#
Timing for the overlay primitives is set once in globals.css by data-slot, unlayered so it outranks each component's animation classes.
Overlay timings
Each trigger opens a primitive with the timing written beside it. None of it is set in the component.
import { Button } from "@oration/canon/components/button";import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@oration/canon/components/collapsible";import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@oration/canon/components/dialog";import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger,} from "@oration/canon/components/popover";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ChevronDownIcon } from "lucide-react";import * as React from "react";export function SlotTimings() { const [open, setOpen] = React.useState(false); return ( <div className="flex w-full max-w-md flex-col gap-3"> <div className="flex flex-wrap items-center gap-2"> <Popover> <PopoverTrigger render={ <Button type="button" variant="outline" size="sm" /> } > Popover, 160ms </PopoverTrigger> <PopoverContent> <PopoverHeader> <PopoverTitle>Early-pay discount</PopoverTitle> <PopoverDescription> Opens from 0.97 over 160ms on ease-out, and closes in 110ms. </PopoverDescription> </PopoverHeader> </PopoverContent> </Popover> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="outline" size="sm" /> } > Tooltip, 125ms </TooltipTrigger> <TooltipContent>Download remittance PDF</TooltipContent> </Tooltip> <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger render={ <Button type="button" variant="outline" size="sm" /> } > Dialog, 200ms </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Hold payment to Halcyon</DialogTitle> <DialogDescription> The dialog entered from 0.96 over 200ms. It leaves toward 0.98 in 140ms. </DialogDescription> </DialogHeader> <DialogFooter> <DialogClose render={ <Button type="button" variant="outline" /> } > Cancel </DialogClose> <Button type="button" onClick={() => { setOpen(false); toast.add({ title: "Payment held", description: "Halcyon, $12,940.50, until you release it.", }); }} > Hold payment </Button> </DialogFooter> </DialogContent> </Dialog> </div> <Collapsible className="rounded-xl bg-card shadow-border"> <CollapsibleTrigger className="group flex h-10 w-full items-center justify-between rounded-xl px-3 text-13 font-medium text-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/40"> Remittance details, 200ms <ChevronDownIcon aria-hidden="true" className="size-4 text-muted-foreground transition-transform duration-200 ease-out group-data-[panel-open]:rotate-180" /> </CollapsibleTrigger> <CollapsibleContent> <dl className="mx-3 mb-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-[10px] bg-muted/70 px-3 py-2 text-13"> <dt className="text-muted-foreground">Sent</dt> <dd className="text-right">Sep 28, 2026</dd> <dt className="text-muted-foreground">Amount</dt> <dd className="text-right font-medium tabular-nums"> $18,240.00 </dd> </dl> </CollapsibleContent> </Collapsible> </div> );}| Slots | Enter | Exit | Notes |
|---|---|---|---|
| Menus, context menus, menubars, popovers, selects, comboboxes, hover cards | 160ms | 110ms | Ease-out, entering from a 0.97 scale. |
tooltip-content | 125ms | 125ms | Ease-out. data-instant removes the animation when moving between adjacent tooltips. |
dialog-content, alert-dialog-content | 200ms | 140ms | Ease-out, from 0.96 and toward 0.98. data-no-motion removes it, with its overlay. |
| Dialog, alert dialog and sheet overlays | 200ms | 200ms | Ease-out fade. |
sheet-content | 320ms | 200ms | A transition on transform, translate and opacity, on ease-drawer. |
collapsible-content | 200ms | 200ms | Height from 0 to --collapsible-panel-height, ease-out. |
/* * Motion timing for primitives, kept unlayered so it outranks the per-component * tw-animate utility classes without editing every component. */:where( [data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"], [data-slot="context-menu-content"], [data-slot="context-menu-sub-content"], [data-slot="menubar-content"], [data-slot="popover-content"], [data-slot="select-content"], [data-slot="combobox-content"], [data-slot="hover-card-content"]) { --tw-animation-duration: 160ms; --tw-ease: var(--ease-out); --tw-enter-scale: 0.97;}:where( [data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"], [data-slot="context-menu-content"], [data-slot="menubar-content"], [data-slot="popover-content"], [data-slot="select-content"], [data-slot="combobox-content"], [data-slot="hover-card-content"])[data-closed] { --tw-animation-duration: 110ms;}[data-slot="tooltip-content"] { --tw-animation-duration: 125ms; --tw-ease: var(--ease-out);}[data-slot="tooltip-content"][data-instant] { animation: none;}:where([data-slot="dialog-content"], [data-slot="alert-dialog-content"]) { --tw-animation-duration: 200ms; --tw-ease: var(--ease-out); --tw-enter-scale: 0.96; --tw-exit-scale: 0.98;}:where( [data-slot="dialog-content"], [data-slot="alert-dialog-content"])[data-closed] { --tw-animation-duration: 140ms;}:where( [data-slot="dialog-overlay"], [data-slot="alert-dialog-overlay"], [data-slot="sheet-overlay"]) { --tw-animation-duration: 200ms; --tw-ease: var(--ease-out);}[data-slot="dialog-content"][data-no-motion],[data-slot="dialog-overlay"]:has(+ [data-no-motion]) { animation: none;}[data-slot="sheet-content"] { transition-property: transform, translate, opacity; transition-duration: 320ms; transition-timing-function: var(--ease-drawer);}[data-slot="sheet-content"][data-ending-style] { transition-duration: 200ms;}[data-slot="collapsible-content"] { overflow: hidden; height: var(--collapsible-panel-height); transition: height 200ms var(--ease-out);}[data-slot="collapsible-content"][data-starting-style],[data-slot="collapsible-content"][data-ending-style] { height: 0;}@media (prefers-reduced-motion: reduce) { .t-resize { /* biome-ignore lint/complexity/noImportantStyles: snippet verbatim */ transition: none !important; }}Under prefers-reduced-motion, every enter and exit scale, translate and blur is pinned to its resting value, smooth scrolling stops, sheets only fade, disclosures snap and both shimmers stop. Opacity still fades, so state changes stay visible.
The base layer#
Rules every element gets for free. Nothing here needs a class, and nothing in a component should fight it.
Caret, selection, focus, icons and scrollbars
Type in the field, select the sentence, press Tab, compare the icon strokes and scroll the well.
Select this sentence to see the 22% indigo selection. Tab to this link or for the 2px outline at a 2px offset.
Northwind Freight
Halcyon
Orchard Street
Cedar Logistics
Brightline Supply
Kestrel Foods
import { Input } from "@oration/canon/components/input";import { ReceiptIcon } from "lucide-react";import Link from "next/link";export function BaseLayer() { return ( <div className="flex w-full max-w-lg flex-col gap-4"> <div className="flex flex-col gap-1.5"> <label htmlFor="base-memo" className="text-xs font-medium text-foreground" > Memo </label> <Input id="base-memo" defaultValue="Click here: the caret is indigo" /> </div> <p className="text-13 text-foreground"> Select this sentence to see the 22% indigo selection. Tab to{" "} <Link href="/design/foundations/styles#base-layer" className="font-medium underline underline-offset-[3px]" > this link </Link>{" "} or{" "} <button type="button" className="font-medium text-foreground underline underline-offset-[3px]" > this bare button </button>{" "} for the 2px outline at a 2px offset. </p> <div className="flex items-center gap-6 text-13 text-muted-foreground"> <span className="flex items-center gap-2"> <ReceiptIcon aria-hidden="true" className="size-4 text-foreground" /> 1.75, Canon </span> <span className="flex items-center gap-2"> <ReceiptIcon aria-hidden="true" className="size-4 text-foreground" style={{ strokeWidth: 2 }} /> 2, lucide's default </span> </div> <div className="h-24 overflow-y-auto rounded-[10px] bg-muted/70 px-3 py-2 text-13 text-foreground"> {[ "Northwind Freight", "Halcyon", "Orchard Street", "Cedar Logistics", "Brightline Supply", "Kestrel Foods", ].map((name) => ( <p key={name} className="py-1"> {name} </p> ))} </div> </div> );}| Rule | Effect |
|---|---|
* { @apply border-border outline-ring/50 } | A bare border is a Hairline, and outlines default to Focus Indigo at 50%. |
body | White Plane and Graphite Ink, antialiased, 14px on 1.45 with cv11 and ss01 on. |
html | -webkit-text-size-adjust: 100% and text-rendering: optimizeLegibility. |
h1 to h4, p | text-wrap: balance on headings and pretty on paragraphs. |
input, textarea | caret-color: var(--primary), the indigo caret. |
::selection | Quiet Indigo at 22%. |
svg.lucide | Stroke 1.75px for every lucide icon, instead of lucide's 2. |
a, button, [role=button], summary on :focus-visible | A 2px Focus Indigo outline at a 2px offset with 6px corners. It sits in the base layer, so a component's own focus utilities win. |
* scrollbars | scrollbar-width: thin with a Firm Hairline thumb on a transparent track. |
@layer base { * { @apply border-border outline-ring/50; } html { -webkit-text-size-adjust: 100%; text-rendering: optimizeLegibility; } body { @apply bg-background text-foreground antialiased; font-size: 0.875rem; line-height: 1.45; font-feature-settings: "cv11", "ss01"; } h1, h2, h3, h4 { text-wrap: balance; } p { text-wrap: pretty; } input, textarea { caret-color: var(--primary); } ::selection { background: color-mix(in oklch, var(--primary) 22%, transparent); } svg.lucide { stroke-width: 1.75px; } :where(a, button, [role="button"], summary):focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 6px; } * { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }}Canonical recipes#
Five class strings that build most of the product. Copy them as written; the variations people invent are where drift starts.
| Recipe | Classes | Notes |
|---|---|---|
| Raised surface | rounded-xl bg-card p-4 shadow-border | Add transition-shadow duration-150 ease-out hover:shadow-border-hover and a focus ring when the whole card is a link. |
| Tint well | rounded-[10px] bg-muted/70 px-3 py-2 | A sub-region inside a card. No edge, no shadow, never a second card. |
| List row | List px-2 pb-2, row h-9 rounded-md px-2 text-13 hover:bg-muted | Rows inset 8px from the card and fill Well Gray on hover. |
| Toolbar control | Button size="sm" (28px) with className="text-[13px]", icon buttons size="icon-sm", fields h-7 | Toolbars are Body Dense. Fields keep text-base md:text-13 so phones don't zoom. |
| Meta line | flex flex-wrap gap-x-3 text-xs text-muted-foreground, figures tabular-nums | Values separated by space, never by dots. The amount takes Graphite Ink and sits right. |
Raised surface
import { ArrowUpRightIcon } from "lucide-react";import Link from "next/link";export function RaisedSurface() { return ( <Link href="/design/foundations/styles#recipes" className="group flex w-full max-w-xs flex-col gap-1 rounded-xl bg-card p-4 shadow-border transition-shadow duration-150 ease-out outline-none hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40" > <span className="flex items-center justify-between text-sm font-semibold text-foreground"> Northwind Freight <ArrowUpRightIcon aria-hidden="true" className="size-4 text-muted-foreground transition-colors duration-150 group-hover:text-foreground" /> </span> <span className="text-13 text-muted-foreground"> 14 open invoices, next run Friday </span> </Link> );}Tint well
Payment run, Friday
- Invoices
- 212
- Total
- $1,284,310.42
export function TintWell() { return ( <div className="flex w-full max-w-xs flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <h3 className="text-sm font-semibold text-foreground"> Payment run, Friday </h3> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-[10px] bg-muted/70 px-3 py-2 text-13"> <dt className="text-muted-foreground">Invoices</dt> <dd className="text-right font-medium tabular-nums">212</dd> <dt className="text-muted-foreground">Total</dt> <dd className="text-right font-medium tabular-nums"> $1,284,310.42 </dd> </dl> </div> );}List row
Approvers
import { toast } from "@oration/canon/components/toast";export function ListRow() { const people = [ { name: "Priya Raman", role: "AP manager" }, { name: "Tomás Ferreira", role: "Controller" }, { name: "Aisha Bello", role: "Procurement" }, ]; return ( <div className="flex w-full max-w-xs flex-col rounded-xl bg-card shadow-border"> <div className="flex items-baseline justify-between px-4 pt-4 pb-2"> <h3 className="text-sm font-semibold text-foreground"> Approvers </h3> <button type="button" className="rounded-sm text-xs text-muted-foreground hover:text-foreground" onClick={() => toast.add({ title: "Approver list opened" })} > View all </button> </div> <ul className="flex flex-col px-2 pb-2"> {people.map((person) => ( <li key={person.name}> <button type="button" onClick={() => toast.add({ title: person.name, description: person.role, }) } className="flex h-9 w-full items-center justify-between gap-3 rounded-md px-2 text-left text-13 outline-none transition-colors duration-150 ease-out hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" > <span className="truncate text-foreground"> {person.name} </span> <span className="text-xs text-muted-foreground"> {person.role} </span> </button> </li> ))} </ul> </div> );}Meta line
export function MetaLine() { return ( <div className="flex w-full max-w-sm flex-col gap-0.5 rounded-xl bg-card p-4 shadow-border"> <span className="text-13 font-medium text-foreground"> INV-20931 </span> <span className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground"> <span>Northwind Freight</span> <span>Net 30</span> <span>Due Oct 2</span> <span className="ml-auto text-foreground tabular-nums"> $18,240.00 </span> </span> </div> );}Toolbar controls
28px controls at 13px. The search field is 16px on phones and 13px from 768px.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { DownloadIcon, FilterIcon, MoreHorizontalIcon, SearchIcon, SlidersHorizontalIcon,} from "lucide-react";import * as React from "react";export function ToolbarControl() { const [query, setQuery] = React.useState(""); return ( <div className="flex w-full max-w-xl items-center gap-1.5 rounded-xl bg-background px-2 py-1.5 shadow-border"> <div className="relative w-48"> <SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" /> <input aria-label="Search invoices" placeholder="Search invoices" value={query} onChange={(event) => setQuery(event.target.value)} className="h-7 w-full rounded-md border border-input bg-transparent pr-2 pl-7 text-base outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-13 dark:bg-input/30" /> </div> <Button type="button" variant="ghost" size="sm" className="text-[13px]" > <FilterIcon data-icon="inline-start" aria-hidden="true" /> Filter </Button> <Button type="button" variant="ghost" size="sm" className="text-[13px]" > <SlidersHorizontalIcon data-icon="inline-start" aria-hidden="true" /> Display </Button> <span className="ml-auto flex gap-0.5"> <Button type="button" variant="ghost" size="icon-sm" aria-label="Export CSV" onClick={() => toast.add({ title: "Export started", description: "212 invoices to CSV.", }) } > <DownloadIcon aria-hidden="true" /> </Button> <Button type="button" variant="ghost" size="icon-sm" aria-label="More actions" > <MoreHorizontalIcon aria-hidden="true" /> </Button> </span> </div> );}Merging classes#
Two cn functions exist, and they disagree about text-13.
// Canon's cn knows the two custom sizesimport { cn } from "@oration/canon/lib/utils";cn("text-sm", "text-13"); // "text-13"cn("text-13", "text-muted-foreground"); // "text-13 text-muted-foreground"// packages/canon components merge with the plain cn package, which reads// text-13 as a text color. Next to a color class, one of them is dropped:// cn("text-13", "text-foreground") keeps only "text-foreground"// So pass an arbitrary size through their className:<StatusLabel tone="success" className="text-[13px]">On track</StatusLabel><Button size="sm" className="text-[13px]">Filter</Button>cn from @oration/canon/lib/utils extends tailwind-merge so text-13 and text-2xs count as font sizes. Use it in apps/web. Components in packages/canon import cn from the cn package, which doesn't know them: next to a text color, one of the two is dropped. When you pass a 13px size into a packages/canon component's className, write text-[13px].
The drift shows up inside components too. Tag size="lg" loses its hue ink, and StatusLabel renders at its parent's size. See Tag and Status label.
Code in the docs#
The Canon code highlighter colors syntax with the tag inks. It is the one sanctioned use of the option hues outside select options and identity tints.
Token kinds and their inks#
Read from kit/highlight.tsx on every build. The inks are tuned for 12px text on pale chips, so they read on the code panel in both themes.
| Kind | Class | Sample |
|---|---|---|
| comment | text-subtle-foreground italic | // Friday's run |
| string | text-(--tag-green-fg) | "Northwind Freight" |
| keyword | text-(--tag-violet-fg) | export function |
| component | text-(--tag-blue-fg) | <PaymentRun /> |
| tag | text-(--tag-teal-fg) | <div> |
| attr | text-(--tag-orange-fg) | className |
| number | text-(--tag-amber-fg) | 18240 |
| punct | text-muted-foreground | { } ( ) => |
| prop | text-(--tag-pink-fg) | --primary |
The exception covers code only. Product screens that show code, such as webhook payloads and API keys, use Code block and take its colors from there.