Skip to content

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.

PlusCreate or add
PencilEdit or rename
Trash2Delete
CopyDuplicate or copy to clipboard
SearchSearch
SettingsSettings
MoreHorizontalOverflow menu
XClose or remove
CheckConfirm or selected
ChevronDownExpand or open a list
ArrowRightContinue or navigate
DownloadExport or download
UploadImport or upload
InfoInformational note
AlertTriangleWarning
Loader2Loading (spinning)

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.

SizeClassUse for
14pxsize-[14px]Small buttons, badges, dense tables
16pxsize-4The default — buttons, menus, inputs
18pxsize-[18px]Large buttons and prominent controls
20pxsize-5Empty 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.

Dopair the icon with a label

The label carries the meaning; the icon makes the button findable in a scan.

Don'trely 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.

<Plus aria-hidden />

Dohide decorative icons from assistive tech

When a label is already present, the icon repeats it. Hiding it keeps the announcement clean.

Don'tmix 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.

component.tsx
// 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" />
SituationMarkupWhy
Icon beside visible textaria-hiddenThe text is already the accessible name
Icon-only controlaria-label on the controlNothing else names the action
Standalone status iconrole="img" with aria-labelIt carries meaning on its own
Loading spinneraria-hidden, aria-busy on the controlThe 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.

component.tsx
// 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.