Skip to content

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.
Size tokens
@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-border

Cards, panels, kanban cards, flow nodes, the sidebar search field.

shadow-border-hover

Hover on interactive cards, the expanded approval, a card being dragged.

shadow-popover

The floating bulk action bar.

shadow-xs

Outline buttons.

shadow-sm

Not named in DESIGN.md. Prefer shadow-border or shadow-xs.

shadow-md

Menus, popovers, selects and comboboxes, with a 1px ink ring.

shadow-lg

Dialogs and sheets, with the same ring.

shadow-ring

An @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>    );}
Where the shadows come from
@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-border runs through Tailwind's shadow stack, so it combines with ring-* on the same element. shadow-ring replaces box-shadow outright and drops any ring. Nothing in the product uses shadow-ring; reach for shadow-border.
  • Never pair a CSS border with a shadow-* 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-80
80%
bg-ink-65
65%
bg-ink-45
45%
bg-ink-25
25%
bg-ink-15
15%

Like 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, disclosure
  • ease-in-outThe soft pulse on live dots
  • ease-drawerSheets
  • ease-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-shimmer
text-shimmerFinding email for Wen Zhou…
animate-pulse-softNora is on a call
animate-dash
animate-caret-blink
import { 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>    );}
Motion tokens
@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;}
The shimmer utilities
@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);}
Motion utilities
UtilityValueUse
ease-outcubic-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-outcubic-bezier(0.77, 0, 0.175, 1)Also replaces Tailwind's default. Drives animate-pulse-soft.
ease-drawercubic-bezier(0.32, 0.72, 0, 1)Sheets, over 320ms in and 200ms out.
ease-standardcubic-bezier(0.2, 0, 0, 1)Defined but not used, and not in DESIGN.md. Don't use it.
duration-8080msThe fastest color change: the switch track and its label.
animate-shimmershimmer 1.6s linear infiniteUsed by text-shimmer and skeleton-shimmer.
animate-pulse-softpulse-soft 1.8s var(--ease-in-out) infiniteLive dots and loaders. Wrap it in motion-safe:.
animate-dashdash 0.9s linear infiniteThe running edge on the flow canvas. Needs a dash pattern that sums to 10, such as 6 4.
animate-caret-blinkcaret-blink 1.1s ease-out infiniteThe fake caret in OTP slots and streaming text.
text-shimmerA Slate Meta to Graphite Ink sweep clipped to the textA running label: “Finding email…”. The only gradient-clipped text in Canon.
skeleton-shimmerWell Gray with a 5% ink sweepSkeleton 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>    );}
The masks and no-scrollbar
[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>    );}
Timing by data-slot
SlotsEnterExitNotes
Menus, context menus, menubars, popovers, selects, comboboxes, hover cards160ms110msEase-out, entering from a 0.97 scale.
tooltip-content125ms125msEase-out. data-instant removes the animation when moving between adjacent tooltips.
dialog-content, alert-dialog-content200ms140msEase-out, from 0.96 and toward 0.98. data-no-motion removes it, with its overlay.
Dialog, alert dialog and sheet overlays200ms200msEase-out fade.
sheet-content320ms200msA transition on transform, translate and opacity, on ease-drawer.
collapsible-content200ms200msHeight from 0 to --collapsible-panel-height, ease-out.
Primitive timing
/* * 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;}
Reduced motion
@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.

1.75, Canon2, lucide's default

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>    );}
Base layer rules
RuleEffect
* { @apply border-border outline-ring/50 }A bare border is a Hairline, and outlines default to Focus Indigo at 50%.
bodyWhite 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, ptext-wrap: balance on headings and pretty on paragraphs.
input, textareacaret-color: var(--primary), the indigo caret.
::selectionQuiet Indigo at 22%.
svg.lucideStroke 1.75px for every lucide icon, instead of lucide's 2.
a, button, [role=button], summary on :focus-visibleA 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.
* scrollbarsscrollbar-width: thin with a Firm Hairline thumb on a transparent track.
@layer base
@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.

Recipes
RecipeClassesNotes
Raised surfacerounded-xl bg-card p-4 shadow-borderAdd transition-shadow duration-150 ease-out hover:shadow-border-hover and a focus ring when the whole card is a link.
Tint wellrounded-[10px] bg-muted/70 px-3 py-2A sub-region inside a card. No edge, no shadow, never a second card.
List rowList px-2 pb-2, row h-9 rounded-md px-2 text-13 hover:bg-mutedRows inset 8px from the card and fill Well Gray on hover.
Toolbar controlButton size="sm" (28px) with className="text-[13px]", icon buttons size="icon-sm", fields h-7Toolbars are Body Dense. Fields keep text-base md:text-13 so phones don't zoom.
Meta lineflex flex-wrap gap-x-3 text-xs text-muted-foreground, figures tabular-numsValues 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

INV-20931Northwind FreightNet 30Due Oct 2$18,240.00
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>    );}
HalcyonW-9 on file, updated Sep 21
Do. Split a card with a tint well.
HalcyonW-9 on file, updated Sep 21
Don't. Nest a bordered, shadowed card inside it.

Merging classes#

Two cn functions exist, and they disagree about text-13.

cn and the custom sizes
// 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.

Highlighter colors
KindClassSample
commenttext-subtle-foreground italic// Friday's run
stringtext-(--tag-green-fg)"Northwind Freight"
keywordtext-(--tag-violet-fg)export function
componenttext-(--tag-blue-fg)<PaymentRun />
tagtext-(--tag-teal-fg)<div>
attrtext-(--tag-orange-fg)className
numbertext-(--tag-amber-fg)18240
puncttext-muted-foreground{ } ( ) =>
proptext-(--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.