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.
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> );}svg.lucide { stroke-width: 1.75px; }- Import the
*Iconnames (PlusIcon, notPlus), 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
Loader2Iconthrough Spinner; don't spin other icons.
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" />The icons the product uses#
The most imported icons in apps/web, grouped by what they mean. Reach for these first, and use the same icon for the same meaning everywhere.
Gallery
Filter by name and switch sizes. Click an icon to copy its import name.
Create and change
Move and disclose
Status and outcome
Runs and time
Messages and calls
Agents and automation
Records and settings
import { Input } from "@oration/canon/components/input";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { toast } from "@oration/canon/components/toast";import { ArrowLeftIcon, ArrowRightIcon, ArrowUpRightIcon, AtSignIcon, BotIcon, BracesIcon, Building2Icon, CalendarClockIcon, CalendarIcon, CalendarPlusIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronsUpDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, CircleXIcon, CopyIcon, DownloadIcon, EyeIcon, EyeOffIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, HistoryIcon, KeyRoundIcon, LinkIcon, ListFilterIcon, LoaderCircleIcon, LockIcon, type LucideIcon, MailIcon, MegaphoneIcon, MessageSquareIcon, MicIcon, MoreHorizontalIcon, PauseIcon, PencilIcon, PhoneCallIcon, PhoneIcon, PlayIcon, PlusIcon, RefreshCwIcon, RotateCcwIcon, RotateCwIcon, SearchIcon, SendIcon, SettingsIcon, SparklesIcon, TimerOffIcon, Trash2Icon, TriangleAlertIcon, UploadIcon, UserPlusIcon, UsersIcon, WandSparklesIcon, WorkflowIcon, XIcon,} from "lucide-react";import * as React from "react";export function Gallery() { const groups: { name: string; icons: [string, LucideIcon][] }[] = [ { name: "Create and change", icons: [ ["PlusIcon", PlusIcon], ["PencilIcon", PencilIcon], ["CopyIcon", CopyIcon], ["Trash2Icon", Trash2Icon], ["UploadIcon", UploadIcon], ["DownloadIcon", DownloadIcon], ["UserPlusIcon", UserPlusIcon], ["CalendarPlusIcon", CalendarPlusIcon], ], }, { name: "Move and disclose", icons: [ ["ChevronDownIcon", ChevronDownIcon], ["ChevronRightIcon", ChevronRightIcon], ["ChevronsUpDownIcon", ChevronsUpDownIcon], ["ArrowLeftIcon", ArrowLeftIcon], ["ArrowRightIcon", ArrowRightIcon], ["ArrowUpRightIcon", ArrowUpRightIcon], ["MoreHorizontalIcon", MoreHorizontalIcon], ["XIcon", XIcon], ], }, { name: "Status and outcome", icons: [ ["CheckIcon", CheckIcon], ["CircleCheckIcon", CircleCheckIcon], ["CircleAlertIcon", CircleAlertIcon], ["TriangleAlertIcon", TriangleAlertIcon], ["CircleXIcon", CircleXIcon], ["CircleDashedIcon", CircleDashedIcon], ["LoaderCircleIcon", LoaderCircleIcon], ["LockIcon", LockIcon], ], }, { name: "Runs and time", icons: [ ["PlayIcon", PlayIcon], ["PauseIcon", PauseIcon], ["RotateCwIcon", RotateCwIcon], ["RotateCcwIcon", RotateCcwIcon], ["RefreshCwIcon", RefreshCwIcon], ["HistoryIcon", HistoryIcon], ["CalendarClockIcon", CalendarClockIcon], ["TimerOffIcon", TimerOffIcon], ], }, { name: "Messages and calls", icons: [ ["SendIcon", SendIcon], ["MailIcon", MailIcon], ["MessageSquareIcon", MessageSquareIcon], ["AtSignIcon", AtSignIcon], ["MegaphoneIcon", MegaphoneIcon], ["PhoneIcon", PhoneIcon], ["PhoneCallIcon", PhoneCallIcon], ["MicIcon", MicIcon], ], }, { name: "Agents and automation", icons: [ ["SparklesIcon", SparklesIcon], ["WandSparklesIcon", WandSparklesIcon], ["BotIcon", BotIcon], ["WorkflowIcon", WorkflowIcon], ["BracesIcon", BracesIcon], ], }, { name: "Records and settings", icons: [ ["FileTextIcon", FileTextIcon], ["FileSpreadsheetIcon", FileSpreadsheetIcon], ["CalendarIcon", CalendarIcon], ["UsersIcon", UsersIcon], ["Building2Icon", Building2Icon], ["SearchIcon", SearchIcon], ["ListFilterIcon", ListFilterIcon], ["EyeIcon", EyeIcon], ["EyeOffIcon", EyeOffIcon], ["KeyRoundIcon", KeyRoundIcon], ["GlobeIcon", GlobeIcon], ["LinkIcon", LinkIcon], ["SettingsIcon", SettingsIcon], ], }, ]; const [query, setQuery] = React.useState(""); const [size, setSize] = React.useState<"12" | "14" | "16">("16"); const needle = query.trim().toLowerCase(); const visible = groups .map((group) => ({ ...group, icons: group.icons.filter(([name]) => name.toLowerCase().includes(needle), ), })) .filter((group) => group.icons.length > 0); return ( <div className="flex w-full min-w-0 flex-col gap-5"> <div className="flex flex-wrap items-center gap-2"> <Input type="search" aria-label="Filter icons" placeholder="Filter icons" value={query} onChange={(event) => setQuery(event.target.value)} className="h-7 w-56 md:text-[13px]" /> <SegmentedControl<"12" | "14" | "16"> label="Icon size" value={size} onValueChange={setSize} options={[ { value: "12", label: "12px" }, { value: "14", label: "14px" }, { value: "16", label: "16px" }, ]} /> </div> {visible.length === 0 ? ( <p className="text-13 text-muted-foreground"> No icon in the set matches “{query}”. </p> ) : null} {visible.map((group) => ( <section key={group.name} className="flex flex-col gap-2"> <h4 className="text-13 font-medium text-foreground"> {group.name} </h4> <ul className="grid grid-cols-2 gap-1 sm:grid-cols-4 lg:grid-cols-5"> {group.icons.map(([name, Icon]) => ( <li key={name}> <button type="button" onClick={() => { void navigator.clipboard?.writeText( name, ); toast.add({ title: `Copied ${name}` }); }} className="group/icon flex h-10 w-full items-center gap-2.5 rounded-lg px-2.5 text-left outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" > <Icon aria-hidden="true" className="shrink-0 text-muted-foreground group-hover/icon:text-foreground" style={{ width: Number(size), height: Number(size), }} /> <span className="truncate font-mono text-2xs text-muted-foreground group-hover/icon:text-foreground"> {name.replace(/Icon$/, "")} </span> </button> </li> ))} </ul> </section> ))} </div> );}| Meaning | Icon | Not |
|---|---|---|
| Add or create | PlusIcon | CirclePlusIcon, SquarePlusIcon |
| Close, clear, remove from a set | XIcon | CircleXIcon, which means failed |
| More actions on a row or record | MoreHorizontalIcon | MoreVerticalIcon, EllipsisIcon |
| Delete | Trash2Icon | TrashIcon, XIcon |
| Agent or AI action | SparklesIcon | BotIcon, which is the agent itself |
| Open elsewhere | ArrowUpRightIcon | ExternalLinkIcon |
| Back to the parent | ArrowLeftIcon | ChevronLeftIcon, which pages |
| Retry or run again | RotateCwIcon | RefreshCwIcon, which syncs |
| Reset or undo | RotateCcwIcon | UndoIcon |
| Warning | TriangleAlertIcon | AlertTriangleIcon, the old name |
| Error | CircleAlertIcon | TriangleAlertIcon |
| Run or test | PlayIcon | ZapIcon |
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
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> );}| Size | Class | Where |
|---|---|---|
| 16px | size-4 | Default and large buttons, icon buttons, menu items, sidebar items, input adornments, toasts and callouts. |
| 14px | size-3.5 | Small buttons and toolbars, data grid column headers (in Faint Slate), dense rows and inline status beside 13px text. |
| 12px | size-3 | Extra-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
smand 12px forxs, and an explicitsize-*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.5beside 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> );}| State | Token | Example |
|---|---|---|
| Resting | text-muted-foreground | Sidebar items, row actions, menu items, input icons. |
| Active or hovered | text-foreground | The current nav item, a hovered row action. |
| Quiet | text-subtle-foreground | Column header icons, empty-cell markers. |
| On a filled button | inherits text-primary-foreground | The one filled button's icon. |
| Destructive | text-destructive | A delete action, an error beside its message. |
| Outcome | text-success, text-warning | A passed check, an overdue warning, always beside a label. |
The Quiet Indigo Rule
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> );}button.- Decorative icons, the ones beside a visible label, take
aria-hidden="true". The label already says it. - Icon-only buttons take an
aria-labelthat 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-xsis 24px; use it only in dense rows.
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>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.
bg-muted tile with 10px corners and a Slate Meta glyph.<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>- 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.