Skip to content
Highlight

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.

tsx
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.SubmenuTrigger and Cell.Icon size any unsized svg inside them. Adding size-4 by hand to a Button left= icon is how a toolbar ends up with four different icon sizes.
  • Colour comes from the text. Icons are stroke: currentColor, so text-foreground-secondary on 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 .lucide sets 1.15 and draws it with vector-effect: non-scaling-stroke, so a 12px icon and a 20px one share one line weight. Override it with a stroke-* utility, a .lucide rule, the variable on a subtree, or a strokeWidth other 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 the aria-label, not the icon.

Four steps, picked by the text beside the icon.

tsx
// 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-center re-exports text-align-center) and they still compile, but only canonical names are listed here. Search finds the real name.