Icons
One family, one stroke weight, six sizes. An icon is a mnemonic for something the user already knows — it is not a substitute for telling them.
Also called Icon, Glyph, Pictogram — in this system all of them are Icons.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Plus
Create something new
X
Close or remove
Search
Find
Settings
Configure
Ellipsis
More actions
Check
Done or selected
ChevronRight
Navigate forward, expand
Trash2
Delete permanently
Pencil
Edit in place
Copy
Duplicate to clipboard
Download
Save to device
Upload
Send from device
Filter
Narrow a result set
RefreshCw
Reload data
Bell
Notifications
User
Account or person
Share2
Send elsewhere
Archive
Remove without deleting
ExternalLink
Opens outside the app
Info
Supplementary detail
Sizes
Six steps. Icons scale sub-linearly with their container — a 1.5× taller button gets a 1.2× larger icon, or the glyph starts to dominate the label.
Labelled vs unlabelled
The left row is unambiguous to a first-time user. The right row is a quiz. Both are the same four actions.
Optical alignment
Icons and text share a baseline, not a box. Centring on the line box drops the glyph roughly 1px too low at every size.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Every glyph is drawn on a 24×24 grid with 2px of clearance, so icons of different shapes occupy the same optical area.
- Canvas24 × 24
Fixed viewBox regardless of rendered size. Scaling the viewBox instead of the render size is what makes stroke weights drift between icons.
- Live area20 × 20
2px of padding on every side so a circle and a square read as the same visual weight. Without it, full-bleed glyphs look larger than inset ones.
- Stroke weight1.75px at 24px
Scales with the icon, so a 16px icon renders at ~1.17px. Fixing the stroke in absolute pixels makes small icons look heavy and large ones look spindly.
- Corner radius2px, round caps
Matches the softness of our UI radius scale. Sharp caps in a rounded interface look borrowed from another product.
- Optical centre≈1px right of true centre
Asymmetric glyphs — play triangles, send arrows — need nudging. Mathematical centring makes them look like they are drifting left.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-fg-muted | — | Decorative and wayfinding icons |
| --ds-fg-secondary | — | Interactive icons at rest |
| --ds-fg | — | Interactive icons on hover |
| --ds-accent-text | — | Active or selected state |
Spacing
| Token | Value | Used for |
|---|---|---|
| icon-xs | xs buttons, badges, inline markers | |
| icon-sm | sm buttons, table cells, chips | |
| icon-md | Default: buttons, nav, inputs | |
| icon-lg | lg buttons, section headers | |
| icon-xl | FAB, prominent affordances | |
| icon-2xl | Empty states, feature cards | |
| icon gap | Space between an icon and its label |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Icon | Label gap | When to use |
|---|---|---|---|
| 13px | 13px | 6px | Inside 28px controls and badges. The floor — below this, strokes disappear. |
| 14px | 14px | 6px | Inside 32px controls, table cells, chips. |
| 16px | 16px | 8px | The default. 36px buttons, navigation rows, input adornments. |
| 18px | 18px | 8px | 44px buttons, section headers, list leading icons. |
| 22px | 22px | 12px | Floating action buttons, prominent affordances. |
| 32px+ | 32–48px | 16px | Empty states and feature cards. Never inside a control. |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- A meaningful icon must reach 3:1 against its background — it counts as a non-text graphic under WCAG 1.4.11.
- Purely decorative icons are exempt, but if you cannot say confidently that removing it loses no information, it is not decorative.
- Thin strokes at small sizes effectively reduce contrast. At 13px, use --ds-fg-secondary rather than --ds-fg-muted.
Keyboard
| Tab | Reaches icon buttons in DOM order. A decorative icon is never focusable. |
| Enter / Space | Activates an icon button exactly like any other button. |
Screen readers
- The visible label and the accessible name must match. Voice-control users say what they see, so an aria-label of "Remove item" on a button labelled "Delete" is unusable for them.
- Never use an emoji as a UI icon. Screen readers read the full Unicode name, which is verbose and often absurd in context.
- Icon fonts are announced as random characters when the font fails to load. Use SVG.
Focus & touch
- Icon buttons take the standard focus ring. Because they are square and small, the 2px offset matters more than usual — a flush ring on a 32px square is hard to see.
- A 16px icon inside a 36px button still needs a 44px pointer target. Our IconButton pads the hit area on coarse pointers without changing the visual size.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-hidden="true" | Decorative icons | Any icon beside a text label. Without it, screen readers may announce a filename or an unhelpful title. |
| aria-label | Icon-only buttons | Required. The button has no other accessible name and announces as "button". |
| role="img" + aria-label | A standalone meaningful icon | For a status glyph that is not inside a control but does carry information. |
| focusable="false" | <svg> | Internet Explorer legacy, still worth setting — some tooling makes inline SVG focusable by default. |
Example usage
1// Named imports only — a namespace import kills tree-shaking2import { Save, Trash2, Search } from 'lucide-react'34// Decorative: hidden from assistive tech5<span className="inline-flex items-center gap-2">6 <Save size={16} aria-hidden />7 Save changes8</span>910// Icon-only: aria-label is mandatory11<IconButton label="Delete project" icon={<Trash2 />} />1213// Standalone meaningful icon14<Check size={16} role="img" aria-label="Verified" className="text-success-text" />1516// Sizing scales sub-linearly with the control17const ICON_FOR = { xs: 13, sm: 14, md: 16, lg: 18 } as const18<Icon size={ICON_FOR[size]} />1920// A custom glyph must match the family: 24 viewBox, 1.75 stroke, round caps21export function Custom({ size = 16, ...props }) {22 return (23 <svg24 width={size} height={size} viewBox="0 0 24 24"25 fill="none" stroke="currentColor" strokeWidth={1.75}26 strokeLinecap="round" strokeLinejoin="round"27 aria-hidden focusable="false" {...props}28 >29 <path d="M4 12h16M12 4v16" />30 </svg>31 )32}CSS
/* Icons inherit colour, so one rule themes every glyph */
.icon { stroke: currentColor; fill: none; }
/* Optical alignment: match the cap height, not the line box */
.label-with-icon {
display: inline-flex;
align-items: center;
gap: 0.5rem;
line-height: 1; /* stops the line box dragging the icon down */
}
/* Asymmetric glyphs need a nudge inside a circular button */
.btn--play .icon { transform: translateX(1px); }
/* Never let an icon shrink in a flex row */
.icon { flex-shrink: 0; }
/* Spin at a constant rate — a spinner is mechanical, not organic */
@keyframes spin { to { transform: rotate(360deg) } }
.icon--loading { animation: spin 720ms linear infinite; }
@media (prefers-reduced-motion: reduce) {
.icon--loading { animation-duration: 2s; }
}Professional tips
- Keep a single file that maps every icon in the product to its one meaning. It is the cheapest way to stop the pencil from becoming three different actions.
- When you need a glyph the family lacks, draw it on the same 24px grid at the same stroke rather than importing a second library for one icon.
- Icons in a vertical list should share a fixed-width column, so the labels align even when the glyphs have different widths.
- For directional icons, mirror them in RTL locales — but never mirror a clock, a play button, or anything containing text.
Performance
- Inline SVG components are tree-shakeable and themeable with currentColor. An SVG sprite is smaller for very large sets but loses per-icon code splitting.
- Icon fonts should not be used at all: they block rendering, break in high-contrast mode, and announce as garbage characters if the font fails.
- A component library that imports every icon at the top level ships all of them. Verify with a bundle analyser — this mistake is invisible in development.
- For a list of 500 rows each with three icons, render the SVG once with <use> references rather than 1,500 inline SVG nodes.
Common mistakes
- Forgetting aria-hidden on a decorative icon, so a screen reader announces it before every label.
- Using the same glyph for "remove from list" and "delete permanently". One is reversible; the user cannot tell which.
- Setting stroke-width in absolute pixels, so 13px icons look heavy and 32px icons look weak.
- Letting an icon shrink in a flex container because flex-shrink was not set to 0 — the glyph squashes and the whole row looks broken.
Real-world recommendations
- Test unfamiliar icons by showing five people the glyph alone and asking what it does. Under four correct answers means it needs a label.
- Icon-only toolbars are appropriate for tools used daily for hours. They are wrong for anything used weekly, and they are always wrong for destructive actions.
- When adding a new icon, check whether an existing one already carries that meaning. Most icon sprawl comes from not looking first.
- In localised products, remember that icon conventions are not universal. A shopping trolley, an envelope and a thumbs-up all vary in meaning by region.