Scalable icon systems
Build icons as a set: a shared grid, one stroke width, currentColor, and an accessible Icon component. One vector beats an icon font at every size.
In one line: treat icons as a system — one grid, one stroke, one color hook — and a single vector stays crisp from a 16px toolbar to a 64px hero, no extra assets required.
What it is
An icon system is a set of glyphs that share a drawing grid and a consistent optical weight, not a pile of one-off pictures. Every icon lives on the same canvas (a 24×24 box is the common choice), uses the same stroke width, the same line caps and joins, and the same visual padding. Because they're authored as SVG — paths, lines, and circles described as math — they render sharp at any size and re-stroke uniformly.
That shared grammar is what makes a set feel like a family. A search glyph and a settings glyph drawn on the same grid with the same 2px stroke read as siblings; the same two glyphs pulled from different packs clash. The system is the rules, and the icons are what follow them.
Why it matters
A system buys you four things that loose assets can't. Consistency: shared grid and stroke keep weight even across the whole set, so the UI looks intentional. Theming: drawing with fill="none" stroke="currentColor" means every icon inherits the surrounding text color — one CSS variable recolors the lot for dark mode or brand themes. Accessibility: inline SVG is real DOM you can label, hide, or title per use, instead of a glyph baked into a font that screen readers may announce as garbage. And no FOUT: an icon font has to download before anything paints, flashing blank or fallback boxes; inline SVG ships with the markup and paints immediately, crisp at every size with no hinting hacks.
See it
Tweak it2
Resize the icons and re-stroke them with the two knobs. The same four glyphs are drawn once on a 24×24 grid — one vector each — and they stay crisp and evenly weighted whether you scale them down to a toolbar or up to a hero. No new files load; the markup just scales.
How it works
Every glyph is authored on the same 24×24 viewBox, so coordinates are shared and the optical weight matches across the set. Each icon uses identical stroke settings — stroke-width, stroke-linecap="round", and stroke-linejoin="round" — and is drawn with fill="none" stroke="currentColor" so it's a line glyph that inherits the current text color. Because the geometry is vector math, scaling the width/height (or the box's CSS size) never blurs an edge.
One sharp edge to know: at very small sizes a thin stroke can shimmer or thin out. Adding vector-effect="non-scaling-stroke" pins the stroke to device pixels so a 1px line stays a crisp 1px regardless of the box scale — handy for dense toolbars.
The reusable piece is a small <Icon name size title?> component. When it has no title it's decorative: mark the <svg> aria-hidden="true" so screen readers skip it (the adjacent text carries the meaning). When you pass a title — for an icon-only button — give the <svg> role="img" and a <title> child, which becomes its accessible name.
Build it
A minimal accessible Icon: decorative by default, properly labelled when you pass a title.
import type { ReactNode } from "react";
type IconProps = {
/** Path/shape children that form the glyph, drawn on a 24×24 grid. */
children: ReactNode;
/** Stable name, used for the title's id when labelled. */
name: string;
/** Rendered box in px (defaults to 24). */
size?: number;
/** Stroke width in user units (defaults to 2). */
stroke?: number;
/** If set, the icon is meaningful: role=img + <title>. If omitted, decorative. */
title?: string;
};
export function Icon({ children, name, size = 24, stroke = 2, title }: IconProps) {
const labelled = typeof title === "string" && title.length > 0;
const titleId = labelled ? `icon-${name}-title` : undefined;
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
role={labelled ? "img" : undefined}
aria-hidden={labelled ? undefined : true}
aria-labelledby={titleId}
focusable="false"
>
{labelled ? <title id={titleId}>{title}</title> : null}
{children}
</svg>
);
}Make it yours
Use the controls beside the demo above to change icon size and stroke width — each change updates the example live.
Experiment in the playground- Shrink the size to its minimum and watch the glyphs stay crisp — the vector re-rasterizes instead of blurring like a bitmap would.
- Push the stroke from 1 to 3: every icon re-weights together, because they share one grid and one stroke setting.
- Imagine swapping
text-accentfortext-foregroundon the row — every icon recolors at once throughcurrentColor, no per-icon edits.
Reproduce it with an LLM
Reproduce it with an LLM
You are a design-systems engineer. Design a tiny SVG icon system of four line icons (search, settings, user, close) that feel like a set: a shared 24×24 grid, a consistent stroke width and corner radius, `fill="none" stroke="currentColor"` so they inherit color, and `vector-effect="non-scaling-stroke"` consideration for tiny sizes. Show how to ship them: an accessible React `<Icon name size title?>` component where a missing `title` renders the icon `aria-hidden` (decorative) and a provided `title` adds `role="img"` + `<title>`. Note the tradeoffs vs an icon font and vs separate <img> files. Return the four SVGs plus the component.
Pitfalls & accessibility
- Don't size icons in
pxonly where the surrounding text scales — sizing inemlets icons grow with the text they sit beside, keeping optical balance when users zoom.