Foundations
Icons
One icon set, at its default stroke weight, in four sizes. Icons here are a way to find things faster — they are almost never the only thing carrying a meaning.
The setPermalink to this section
Lucide, imported per-icon so only what is used ships. Its 24px grid and 2px stroke sit naturally beside Inter at these weights.
One meaning per icon
An icon must mean the same thing everywhere in the product. Using a pencil for “edit” in one place and “draft” in another destroys the recognisability that makes icons worth using at all.
SizesPermalink to this section
The icon size is set by the control it sits in, not chosen per instance. Every component already applies the right one.
| Size | Class | Use for |
|---|---|---|
| 14px | size-[14px] | Small buttons, badges, dense tables |
| 16px | size-4 | The default — buttons, menus, inputs |
| 18px | size-[18px] | Large buttons and prominent controls |
| 20px | size-5 | Empty states and section headers |
Optical alignmentPermalink to this section
Icons sit on the text baseline's optical centre, not its geometric one. Components handle this with flex alignment rather than manual offsets.
Icons and meaningPermalink to this section
An icon accelerates recognition for people who already know what it means. It does not teach anyone what it means.
Do — pair the icon with a label
The label carries the meaning; the icon makes the button findable in a scan.
Don't — rely on an icon to explain an action
If someone cannot name the action from the glyph, the icon is decoration. Use a Button with text.
Do — hide decorative icons from assistive tech
When a label is already present, the icon repeats it. Hiding it keeps the announcement clean.
Don't — mix icon sets
Two stroke weights in one row look like a rendering bug. Keep Lucide’s default weight throughout.
AccessibilityPermalink to this section
An icon is either decorative or it is the label. There is no middle case.
// Decorative — a visible label already says it.
// aria-hidden stops it being announced twice.
<Button leadingIcon={<Plus aria-hidden />}>Create project</Button>
// Meaningful — the icon IS the label, so the control
// needs an accessible name. IconButton requires one.
<IconButton label="Delete project" icon={<Trash2 />} />
// Standalone status icon — give it a name, since
// nothing else conveys the state.
<AlertTriangle role="img" aria-label="Warning" className="size-4" />| Situation | Markup | Why |
|---|---|---|
| Icon beside visible text | aria-hidden | The text is already the accessible name |
| Icon-only control | aria-label on the control | Nothing else names the action |
| Standalone status icon | role="img" with aria-label | It carries meaning on its own |
| Loading spinner | aria-hidden, aria-busy on the control | The busy state is announced, not the glyph |
Adding an iconPermalink to this section
Icons are imported individually so the bundle only carries what is used.
// Named imports — tree-shaken, so only these two ship.
import { Plus, Trash2 } from "lucide-react";
// Never import the whole set:
// import * as Icons from "lucide-react"; ← ships everything
// Size and colour come from the component. Passing a
// className with a size overrides that on purpose only.
<Plus aria-hidden />Before adding a new icon
Check whether an existing icon already covers the meaning. A product with eighty icons has no icon language — it has eighty small pictures nobody can tell apart.