Skip to content

Iconography

Lucide icons at a 1.75 stroke, sized 12 to 16px, always with an accessible name when they stand alone.

Lucide at 1.75#

Every icon comes from lucide-react. One rule in globals.css sets the stroke to 1.75 for all of them, a touch lighter than Lucide's default so icons sit at the weight of 13px text.

Three stroke widths at 16px

1.75 matches the stem of Geist at 13 and 14px. 2 reads heavy next to dense text, and 1.5 breaks up at 14px.

1.5Too faint at 14px
1.75Canon
2Lucide's default
import { cn } from "@oration/canon/lib/utils";import {  Building2Icon,  CalendarClockIcon,  FileTextIcon,  type LucideIcon,  SendIcon,} from "lucide-react";export function Stroke() {    const strokes = [        { width: 1.5, label: "1.5", note: "Too faint at 14px" },        { width: 1.75, label: "1.75", note: "Canon" },        { width: 2, label: "2", note: "Lucide's default" },    ];    const icons: [string, LucideIcon][] = [        ["file", FileTextIcon],        ["send", SendIcon],        ["calendar", CalendarClockIcon],        ["building", Building2Icon],    ];    return (        <div className="grid w-full max-w-lg grid-cols-3 gap-3">            {strokes.map((stroke) => (                <div                    key={stroke.label}                    className={cn(                        "flex flex-col items-center gap-3 rounded-[10px] px-3 py-4",                        stroke.width === 1.75 ? "bg-muted/70" : "",                    )}                >                    <div className="flex items-center gap-3 text-foreground">                        {icons.map(([name, Icon]) => (                            <Icon                                key={name}                                aria-hidden="true"                                className="size-4"                                style={{ strokeWidth: stroke.width }}                            />                        ))}                    </div>                    <div className="flex flex-col items-center">                        <span className="text-13 font-medium tabular-nums">                            {stroke.label}                        </span>                        <span className="text-xs text-muted-foreground">                            {stroke.note}                        </span>                    </div>                </div>            ))}        </div>    );}
The stroke rule in globals.css
svg.lucide {    stroke-width: 1.75px;  }
  • Import the *Icon names (PlusIcon, not Plus), so icons can't be confused with components.
  • Don't pass strokeWidth. The CSS rule wins over the attribute anyway, so files that still pass it get 1.75 regardless.
  • A custom SVG that sits beside Lucide icons is drawn on a 24px grid with a 1.75 stroke, round caps and joins, in currentColor.
  • Spinners use Loader2Icon through Spinner; don't spin other icons.
Importing and placing an icon
import { CalendarClockIcon, PlusIcon } from "lucide-react";// Inside a Button: no size class, the button sizes it<Button variant="outline">  <PlusIcon data-icon="inline-start" aria-hidden="true" />  Add supplier</Button>// Standalone: size it, color it, hide it from assistive tech<CalendarClockIcon aria-hidden="true" className="size-4 text-muted-foreground" />

Three sizes#

16px by default, 14px in small and dense places, 12px in the smallest controls. Nothing in the interface chrome is larger than 16px.

Each size where it goes

16px
Resend remittanceCopy invoice ID
Default buttons, menus, sidebar items, inputs.
14px
Due dateOct 2, overdue
Small buttons, toolbars, column headers, dense rows.
12px
PassedExtra-small buttons, run badges, inline chips.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import {  CalendarIcon,  CheckIcon,  CopyIcon,  ListFilterIcon,  MailIcon,  PlusIcon,  TriangleAlertIcon,  UploadIcon,} from "lucide-react";export function Sizes() {    return (        <div className="grid w-full max-w-2xl gap-6 sm:grid-cols-3">            <div className="flex flex-col gap-3">                <span className="text-xs text-muted-foreground tabular-nums">                    16px                </span>                <div className="flex flex-wrap items-center gap-2">                    <Button                        type="button"                        variant="outline"                        onClick={() => toast.add({ title: "Invoice uploaded" })}                    >                        <UploadIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                        Upload                    </Button>                </div>                <div className="flex w-44 flex-col rounded-lg bg-popover p-1 text-13 shadow-md ring-1 ring-foreground/10">                    <span className="flex h-7 items-center gap-2 rounded-md bg-accent px-1.5">                        <MailIcon aria-hidden="true" className="size-4" />                        Resend remittance                    </span>                    <span className="flex h-7 items-center gap-2 rounded-md px-1.5">                        <CopyIcon                            aria-hidden="true"                            className="size-4 text-muted-foreground"                        />                        Copy invoice ID                    </span>                </div>                <span className="text-xs text-muted-foreground">                    Default buttons, menus, sidebar items, inputs.                </span>            </div>            <div className="flex flex-col gap-3">                <span className="text-xs text-muted-foreground tabular-nums">                    14px                </span>                <div className="flex flex-wrap items-center gap-2">                    <Button                        type="button"                        variant="outline"                        size="sm"                        onClick={() => toast.add({ title: "Filter added" })}                    >                        <ListFilterIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                        Filter                    </Button>                </div>                <div className="flex flex-col rounded-[10px] bg-card shadow-border">                    <span className="flex h-8 items-center gap-1.5 border-b border-border px-2.5 text-13 font-medium text-muted-foreground">                        <CalendarIcon                            aria-hidden="true"                            className="size-3.5 text-subtle-foreground"                        />                        Due date                    </span>                    <span className="flex h-9 items-center gap-1.5 px-2.5 text-13">                        <TriangleAlertIcon                            aria-hidden="true"                            className="size-3.5 text-warning"                        />                        Oct 2, overdue                    </span>                </div>                <span className="text-xs text-muted-foreground">                    Small buttons, toolbars, column headers, dense rows.                </span>            </div>            <div className="flex flex-col gap-3">                <span className="text-xs text-muted-foreground tabular-nums">                    12px                </span>                <div className="flex flex-wrap items-center gap-2">                    <Button                        type="button"                        variant="outline"                        size="xs"                        onClick={() => toast.add({ title: "Contact added" })}                    >                        <PlusIcon data-icon="inline-start" aria-hidden="true" />                        Add contact                    </Button>                </div>                <span className="inline-flex h-5 w-fit items-center gap-1 rounded-md bg-muted px-1.5 text-xs font-medium text-muted-foreground">                    <CheckIcon aria-hidden="true" className="size-3" />                    Passed                </span>                <span className="text-xs text-muted-foreground">                    Extra-small buttons, run badges, inline chips.                </span>            </div>        </div>    );}
Icon sizes
SizeClassWhere
16pxsize-4Default and large buttons, icon buttons, menu items, sidebar items, input adornments, toasts and callouts.
14pxsize-3.5Small buttons and toolbars, data grid column headers (in Faint Slate), dense rows and inline status beside 13px text.
12pxsize-3Extra-small buttons, run badges, chips and tags, the arrow in a link chip.
  • Inside a Button, leave the icon unsized. The button sets 16px, 14px for sm and 12px for xs, and an explicit size-* class opts out.
  • Larger art belongs to Illustration, not to an icon scaled up. A 48px Lucide icon in a circle is not an empty state.
  • Align an icon to the first line of text, not the block: mt-0.5 beside 13 and 14px copy.

Color#

Icons rest in Slate Meta and turn Graphite Ink when their item is active or hovered. They take a semantic color only beside the words that carry the same meaning.

Resting, active and semantic

Click a nav item. Its icon steps from Slate Meta to ink with the label; nothing turns indigo.

  • Resting: Slate Meta
  • Active or hovered: Graphite Ink
  • Column headers: Faint Slate
  • Errors: Signal Red, beside the message
  • Outcomes: semantic, beside a label
import { cn } from "@oration/canon/lib/utils";import {  Building2Icon,  CalendarClockIcon,  CalendarIcon,  CircleAlertIcon,  CircleCheckIcon,  FileTextIcon,  SearchIcon,  SendIcon,} from "lucide-react";import * as React from "react";export function Color() {    const [current, setCurrent] = React.useState("Payment runs");    const items = [        { label: "Suppliers", icon: Building2Icon },        { label: "Invoices", icon: FileTextIcon },        { label: "Payment runs", icon: CalendarClockIcon },        { label: "Remittances", icon: SendIcon },    ];    return (        <div className="grid w-full max-w-xl gap-6 sm:grid-cols-2">            <nav aria-label="Payables" className="rounded-xl bg-sidebar p-2">                <ul className="flex flex-col gap-px">                    {items.map((item) => {                        const active = item.label === current;                        return (                            <li key={item.label}>                                <button                                    type="button"                                    aria-current={active ? "page" : undefined}                                    onClick={() => setCurrent(item.label)}                                    className={cn(                                        "flex h-7 w-full items-center gap-2.5 rounded-md px-2 text-13 outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40",                                        active                                            ? "bg-sidebar-accent font-medium text-foreground"                                            : "text-sidebar-foreground hover:bg-sidebar-accent/60",                                    )}                                >                                    <item.icon                                        aria-hidden="true"                                        className={cn(                                            "size-4 shrink-0",                                            active                                                ? "text-foreground"                                                : "text-muted-foreground",                                        )}                                    />                                    {item.label}                                </button>                            </li>                        );                    })}                </ul>            </nav>            <ul className="flex flex-col gap-2.5 text-13">                <li className="flex items-center gap-2">                    <SearchIcon                        aria-hidden="true"                        className="size-4 text-muted-foreground"                    />                    Resting: Slate Meta                </li>                <li className="flex items-center gap-2 font-medium">                    <SearchIcon                        aria-hidden="true"                        className="size-4 text-foreground"                    />                    Active or hovered: Graphite Ink                </li>                <li className="flex items-center gap-2 text-muted-foreground">                    <CalendarIcon                        aria-hidden="true"                        className="size-3.5 text-subtle-foreground"                    />                    Column headers: Faint Slate                </li>                <li className="flex items-center gap-2">                    <CircleAlertIcon                        aria-hidden="true"                        className="size-4 text-destructive"                    />                    Errors: Signal Red, beside the message                </li>                <li className="flex items-center gap-2">                    <CircleCheckIcon                        aria-hidden="true"                        className="size-4 text-success"                    />                    Outcomes: semantic, beside a label                </li>            </ul>        </div>    );}
Icon colors
StateTokenExample
Restingtext-muted-foregroundSidebar items, row actions, menu items, input icons.
Active or hoveredtext-foregroundThe current nav item, a hovered row action.
Quiettext-subtle-foregroundColumn header icons, empty-cell markers.
On a filled buttoninherits text-primary-foregroundThe one filled button's icon.
Destructivetext-destructiveA delete action, an error beside its message.
Outcometext-success, text-warningA passed check, an overdue warning, always beside a label.

The Quiet Indigo Rule

Indigo is spent on the view's one filled primary action, on selection, on focus, and on live state that carries a text label (running, active, upcoming), plus the viewer's own markers: the unread dot, an @mention of you, the unsaved-changes dot. It never fills data, tints an icon tile, colors a heading or decorates.

Names and hidden icons#

An icon is either decoration beside words, and hidden, or the only content of a control, and named. Never neither.

Icon-only buttons

Hover or focus each button. Every one has an aria-label and a tooltip with the same words, through Icon action.

import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { CopyIcon, DownloadIcon, HistoryIcon, Trash2Icon } from "lucide-react";export function IconOnly() {    return (        <div className="flex items-center gap-1 rounded-xl bg-card p-1.5 shadow-border">            <IconAction                label="Export payment run"                onClick={() => toast.add({ title: "Export started" })}            >                <DownloadIcon aria-hidden="true" />            </IconAction>            <IconAction                label="Copy run ID"                onClick={() => toast.add({ title: "Copied run_7Hq2xK9" })}            >                <CopyIcon aria-hidden="true" />            </IconAction>            <IconAction                label="Run history"                onClick={() => toast.add({ title: "Opened run history" })}            >                <HistoryIcon aria-hidden="true" />            </IconAction>            <span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />            <IconAction                label="Delete draft run"                onClick={() =>                    toast.add({ type: "error", title: "Draft run deleted" })                }                className="hover:text-destructive"            >                <Trash2Icon aria-hidden="true" />            </IconAction>        </div>    );}
Export payment run
Do. Name the button and show the name in a tooltip. Sighted mouse users and screen readers get the same words.
Don't. Ship a row of bare icons. Refresh, retry, history and link look alike, and a screen reader announces each as button.
  • Decorative icons, the ones beside a visible label, take aria-hidden="true". The label already says it.
  • Icon-only buttons take an aria-label that names the action (Export payment run), not the glyph (Download).
  • When the action toggles, update the label with it: Play recording becomes Pause recording.
  • A status icon never stands alone. A red circle travels with Failed, a check with Passed. See Interaction states.
  • Give icon-only controls a 24px target at least. icon-xs is 24px; use it only in dense rows.
Naming an icon-only button
import { IconAction } from "@oration/canon/components/icon-action";// Names the button and adds the tooltip in one step<IconAction label="Export payment run" onClick={exportRun}>  <DownloadIcon aria-hidden="true" /></IconAction>// By hand: aria-label on the button, the same words in the tooltip<Tooltip>  <TooltipTrigger    render={<Button variant="ghost" size="icon-sm" aria-label="Export payment run" />}  >    <DownloadIcon aria-hidden="true" />  </TooltipTrigger>  <TooltipContent>Export payment run</TooltipContent></Tooltip>

Icons in buttons#

Mark an icon data-icon="inline-start" or "inline-end" and the button tightens its padding on that side, so the icon sits optically centered in the space.

Leading and trailing icons

Leading icons name the action. Trailing icons say what happens next: a chevron opens a menu, an arrow leaves for another app.

import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { ArrowUpRightIcon, ChevronDownIcon, PlusIcon } from "lucide-react";export function DataIcon() {    return (        <div className="flex flex-wrap items-center gap-2">            <Button                type="button"                variant="outline"                onClick={() => toast.add({ title: "New supplier" })}            >                <PlusIcon data-icon="inline-start" aria-hidden="true" />                Add supplier            </Button>            <Button                type="button"                variant="outline"                onClick={() => toast.add({ title: "Opened in NetSuite" })}            >                Open in NetSuite                <ArrowUpRightIcon data-icon="inline-end" aria-hidden="true" />            </Button>            <Button                type="button"                variant="ghost"                onClick={() => toast.add({ title: "Status menu" })}            >                Scheduled                <ChevronDownIcon data-icon="inline-end" aria-hidden="true" />            </Button>        </div>    );}
  • At the default size the side padding is 10px, and 8px on the icon side. Small and extra-small buttons step from 10 to 6px and 8 to 6px.
  • One icon per button. A leading icon and a trailing chevron is the only pair.
  • Don't put an icon on every button in a footer. Reserve it for the action that benefits from a glyph, or none.

Swapping icons#

When a control's icon changes with its state, cross-fade it in place with Icon swap so the button doesn't jump.

Copy and play

Copy shows a check for 1.5s. Play becomes pause. Both icons share one grid cell, so the label never shifts.

import { Button } from "@oration/canon/components/button";import { IconSwap } from "@oration/canon/components/icon-swap";import { CheckIcon, CopyIcon, PauseIcon, PlayIcon } from "lucide-react";import * as React from "react";export function Swap() {    const [copied, setCopied] = React.useState(false);    const [playing, setPlaying] = React.useState(false);    React.useEffect(() => {        if (!copied) return;        const id = window.setTimeout(() => setCopied(false), 1500);        return () => window.clearTimeout(id);    }, [copied]);    return (        <div className="flex items-center gap-2">            <Button                type="button"                variant="outline"                size="sm"                onClick={() => {                    void navigator.clipboard?.writeText("run_7Hq2xK9");                    setCopied(true);                }}            >                <IconSwap swapKey={copied}>                    {copied ? (                        <CheckIcon aria-hidden="true" className="size-3.5" />                    ) : (                        <CopyIcon aria-hidden="true" className="size-3.5" />                    )}                </IconSwap>                {copied ? "Copied" : "Copy run ID"}            </Button>            <Button                type="button"                variant="outline"                size="sm"                aria-pressed={playing}                onClick={() => setPlaying((value) => !value)}            >                <IconSwap swapKey={playing}>                    {playing ? (                        <PauseIcon aria-hidden="true" className="size-3.5" />                    ) : (                        <PlayIcon aria-hidden="true" className="size-3.5" />                    )}                </IconSwap>                {playing ? "Pause recording" : "Play recording"}            </Button>        </div>    );}

Icon swap keys on swapKey and cross-fades from a 0.25 scale with a 4px blur. Change the accessible name with the icon; the swap itself is silent.

Icon swap runs on a 0.3s spring rather than a tier, and under reduced motion the app's MotionConfig drops its scale but keeps the blur. DESIGN.md asks for blur to be zeroed too.

Icon tiles, and no emoji#

Where an icon labels a kind of thing, such as a workflow step or an integration, it sits in a 32px Well Gray tile with 10px corners. Emoji never appear in the interface.

ActionSend remittance
Do. Set the icon in a 32px bg-muted tile with 10px corners and a Slate Meta glyph.
ActionSend remittance
Don't. Tint the tile indigo or make it round. Indigo is spent on the action and selection, and a round tile reads as an avatar.
An icon tile
<span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-muted text-muted-foreground">  <MailIcon aria-hidden="true" className="size-4" /></span>
Payment run approved212 invoices leave Friday, Oct 2.
Do. Show outcome with a Lucide icon in its semantic color beside plain words.
Payment run approved 🚀212 invoices leave Friday, Oct 2. 💸
Don't. Decorate copy with emoji. They render differently on every platform, can't follow the theme and undercut a payments product.
  • Tiles hold one 16px icon. Monogram and identity tiles are a different thing; see Monogram tile.
  • Supplier and integration logos are not icons. Use the vendor's mark at its own colors, or a monogram when there isn't one.
  • No emoji in labels, toasts, empty states, agent messages or seed data.