One icon set through one file: the sizes, the working set, and when an icon needs a word.
One icon set, one stroke, one way in. Every component takes its icons from @/lib/icons and nowhere else, so the whole system changes library by changing that one file. This system uses Lucide.
An icon is sized by the control it sits in, never by eye. It takes the colour of the text next to it, so it follows hover, disabled and dark mode without a rule of its own.
| Icon | Size | Where |
|---|---|---|
| 12px | Inside a badge or an extra-small button. | |
| 14px | Compact controls, size sm. | |
| 16px | The default: buttons, fields, menu rows, table cells. | |
| 20px | Navigation rails and the icon of an empty state. | |
| 24px | An icon that stands alone as the subject, never in a control. |
The icons the components themselves use, by the name they are imported under. The name stays the same when a client swaps the library; only the drawing changes.
A button without words gets an aria-label and a tooltip with the same text. If the action is rare or costly, give it a word instead.
Outline icons at the library's own stroke. Never a filled icon beside an outlined one, and never an emoji standing in for either.
In a list, an icon earns its place when it tells rows apart. The same icon on every row is decoration.
@/lib/icons, by the name ending in Icon. A component never imports an icon package directly.aria-hidden; the label already says it.src/lib/icons.tsx with the same names mapped to another library. Pick one whose corners match the control radius.