Icon System
Build a tree-shakeable icon system with SVG optimization, consistent sizing, and accessibility.
Why
Icons are used 50+ times across a product, and inconsistency in size, stroke width, or alignment is immediately visible. A proper icon system treats icons as components: each SVG is optimized (no editor metadata), tree-shakeable (only used icons ship), sized with the same scale (16/20/24px), and accessible (aria-label or title for decorative vs meaningful). Without this, icons are pasted as raw SVGs with varying stroke widths and viewBoxes, the bundle includes 200 unused icons, and screen readers announce 'image' with no label. The system pays for itself the first time you swap an icon set — it's a one-line change instead of a 200-file find-and-replace.
When
When you have ≥10 icons. Takes 1-2 h with lucide-react or a custom sprite. Skip for text-only apps.
How
1. Use an established library (lucide-react, phosphor-react) or build a custom set
- lucide-react: 1000+ icons, tree-shakeable, consistent 24px viewBox, stroke-based
- Custom: SVGR to convert SVGs to React components with optimization
2. Standardize on one size scale: 16, 20, 24px (1.5x ratio)
- Pass size as a prop: <Icon size={20} /> — never className="w-5 h-5" per icon
- Default to 24px; use 20 for dense UI, 16 for inline text
3. Make every icon accept className and spread props
- <Icon className="text-spark-500" /> for color via currentColor
- Forward ref so tooltips can attach to the icon
4. Distinguish decorative vs meaningful icons for a11y
- Decorative (next to a text label): aria-hidden="true"
- Meaningful (alone, conveys info): aria-label="Close" or a <title> child
5. Add a Storybook page that renders all icons in a grid for visual QA
- Makes it easy to find an icon by name and check consistency at a glanceExample
// Using lucide-react (tree-shakeable, only used icons ship)
import { Search, X, ChevronDown } from "lucide-react";
export function SearchBar() {
return (
<div className="relative">
<Search size={20} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" aria-hidden />
<input className="pl-9" />
<X size={16} className="absolute right-3 top-1/2 -translate-y-1/2" aria-label="Clear search" />
</div>
);
}
// Custom icon with SVGR (svgr.config.js)
module.exports = { svgrConfig: { icon: true, replaceAttrValues: { "#000": "currentColor" } } };
// import Close from "./close.svg?react"; → <Close size={20} />Common Problems
- ⚠Importing the entire icon library (import * as Icons) instead of named imports, shipping 2MB of unused SVGs.
- ⚠Using different stroke widths for icons in the same UI, which looks like a typo.
- ⚠Forgetting aria-hidden on decorative icons, so screen readers announce 'image' between every word.
- ⚠Hardcoding icon color instead of using currentColor, so theming requires editing every icon.
Best Practices
- ✓Use named imports only (import { Search } from 'lucide-react') so the bundler can tree-shake unused icons.
- ✓Standardize on one stroke width (1.5 or 2) for the entire icon set — consistency matters more than the specific value.
- ✓Use currentColor for icon fills/strokes so color is controlled by text-* classes and theming works automatically.
- ✓Run SVGO on custom SVGs to strip editor metadata (Inkscape namespaces, empty groups) — cuts size 40-60%.