Icon system — a shared pictogram language
Icons multiply fast in a product: different packs, different sizes, raw SVGs with unique viewBoxes. An icon system restores order: one wrapper, one size scale, explicit a11y rules.
Choosing a library
Criteria:
- Style — outline / filled / duotone; fit with the UI.
- License — allowed in a commercial product.
- Tree-shaking — import individual icons, not the whole set.
- Customization —
currentColor, stroke-width. - API stability — rare breaking changes.
Popular options: Lucide, Heroicons, Phosphor, a custom set from Figma (SVGR). A DS often locks one primary set plus a process for adding custom brand icons.
Icon wrapper
Do not scatter <svg width="16"> across the codebase. Make an Icon:
const iconSizes = {
xs: 12,
sm: 16,
md: 20,
lg: 24,
xl: 32,
};
export function Icon({ icon: Glyph, size = 'md', className, title, decorative = true, ...props }) {
const px = iconSizes[size] ?? iconSizes.md;
return (
<Glyph
width={px}
height={px}
className={cn('icon', className)}
aria-hidden={decorative ? true : undefined}
role={decorative ? undefined : 'img'}
focusable="false"
{...props}
>
{!decorative && title ? <title>{title}</title> : null}
</Glyph>
);
}
Wrapper rules:
- Size only from the DS scale (
sm/md/lg), not13px. - Color via
currentColor— the icon inherits text/button color. focusable="false"on decorative SVGs in IE/older engines.- Alignment with text:
inline-flex+ a small optical offset when needed.
Decorative vs meaningful
| Type | Example | A11y |
|---|---|---|
| Decorative | icon next to the text “Settings” | aria-hidden="true" |
| Meaningful | “error” status shown only as an icon | accessible name: aria-label / <title> / hidden text |
| Interactive | icon-button “Close” | prefer <button aria-label="Close"> + decorative icon inside |
If nearby visible text already carries the same meaning — the icon is decorative. If the icon carries meaning alone — give it a name.
// Decorative
<button type="button">
<Icon icon={Settings} decorative />
Settings
</button>
// Meaningful (status)
<span className="status">
<Icon icon={AlertCircle} decorative={false} title="Validation error" />
</span>
SVG optimization
- Run through SVGO (strip extra meta, fixed fills when you need currentColor).
- Prefer component imports (
import { Search } from 'lucide-react'). - Do not put huge illustrations into the Icon system — those are illustrations.
Naming and catalog
Agree on a vocabulary: plus, close, chevron-down, search. Document a Storybook gallery of every icon with copyable names — that reduces “we have three different close icons.”
Practice
- Choose a pack and lock it in the DS README.
- Implement
Iconwith a size scale and decorative flag. - Audit: icon-only buttons without labels are bugs.
- Add a gallery to Storybook.
An icon is not decoration — it is part of the interface language. Consistent size, color via tokens, and an explicit a11y policy matter more than “finding a pretty pack.”