Skip to content
3D & graphics for UILesson 2 of 3Beginner14 min

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.

Kept on this device only.

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

Live demo
Tweak it2
32
2
Resize the icons and change the stroke; one vector stays crisp at every size.

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.

tsx
Icon.tsx — one accessible SVG icon component
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-accent for text-foreground on the row — every icon recolors at once through currentColor, 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 px only where the surrounding text scales — sizing in em lets icons grow with the text they sit beside, keeping optical balance when users zoom.

Further reading