FoundationsUpdated Oct 2, 2026
Icons
Lucide, the whole set, straight from the lucide-react already in every workspace. 1854 icons on one 24-unit grid that take their size and their colour from the text beside them, so an icon is never a styling decision.
One import, no wrapper. The icon is a React component and inherits currentColor.
import { Mic, Square } from 'lucide-react' // Inside a @highlight/ui primitive, pass the icon bare and let the component size it<Button left={<Mic />}>Record</Button> // Standalone, size with Tailwind. The icon inherits the text colour around it<Mic className="size-4 text-foreground-secondary" aria-hidden /> // Icon-only controls need an accessible name; the glyph is never the label<Button shape="square" size="sm" aria-label="Stop recording"> <Square /></Button>- Import by name from
lucide-react. Named imports tree-shake, so the bundle carries the icons you used and nothing else. Never import the barrel namespace. - Pass icons bare into primitives.
Button,Menu.Item,Menu.SubmenuTriggerandCell.Iconsize any unsizedsvginside them. Addingsize-4by hand to aButton left=icon is how a toolbar ends up with four different icon sizes. - Colour comes from the text. Icons are
stroke: currentColor, sotext-foreground-secondaryon the row dims the icon with its label. Do not give an icon its own hue to mean something. Pair it with a word. - The stroke is
--icon-stroke-width, 1.15px at every size. Lucide draws at 2 units of its 24-unit box, so its line grows and shrinks with the icon. A base rule on.lucidesets 1.15 and draws it withvector-effect: non-scaling-stroke, so a 12px icon and a 20px one share one line weight. Override it with astroke-*utility, a.luciderule, the variable on a subtree, or astrokeWidthother than 2, which keeps Lucide's scaling stroke. A glyph that carries more weight than the icons around it, like a close X or a checkbox tick, takes--icon-stroke-width-bold, half again the base. - Decorative icons are
aria-hidden. Beside a label, the label is the name. Alone in a button, the button gets thearia-label, not the icon.
Four steps, picked by the text beside the icon.
// Button sizes any unsized descendant svg. Icons step with the text, not the boxsize: { xs: '... text-xs [&_svg:not([class*="size-"])]:size-3', // 20px tall, 12px icon sm: '... text-sm [&_svg:not([class*="size-"])]:size-3.5', // 24px tall, 14px icon md: '... text-sm [&_svg:not([class*="size-"])]:size-3.5', // 28 lg: '... text-sm [&_svg:not([class*="size-"])]:size-3.5', // 32 xl: '... text-sm [&_svg:not([class*="size-"])]:size-3.5', // 40 '2xl': '... text-md [&_svg:not([class*="size-"])]:size-4', // 48px tall, 16px icon} // The :not() is the opt-out. Pass your own size- and the component leaves it alone<Button size="xl" left={<Mic className="size-4" />}>Record</Button>A 40px button carries the same 14px glyph as a 24px one, because the icon sits beside a label and the label does not grow. Standalone icons follow the same rule: size-3 beside text-xs, size-3.5 beside text-sm, size-4 beside text-md, size-5 for a toolbar target with no label. Anything between them is a review flag.
- One set. Lucide is the icon library. A hand-drawn SVG in a feature folder is a fork of the icon system; if the set is genuinely missing a concept, say so rather than adding a one-off.
- A logo is not an icon. Third-party marks are filled, carry their owner's colours, and arrive on their owner's grid. None of that is true of an icon. They live on the Logo component.
- Aliases are not in the set. Lucide keeps deprecated names (
align-centerre-exportstext-align-center) and they still compile, but only canonical names are listed here. Search finds the real name.