An icon set usually comes in one weight, sometimes a few. Put a semibold label next to a regular icon and the row looks unfinished, as if the icon came from somewhere else. Usually it did.
Apple fixed this years ago. SF Symbols comes in nine weights, and each one corresponds to a weight of San Francisco, the system font. The icon is a character in the line, not a picture placed next to it. But Apple's terms limit SF Symbols to apps on Apple's own platforms, and a website isn't one.
The closest open equivalent is Material Symbols, Google's icon set. It ships as a variable font under Apache 2.0: nearly 4,000 icons, one file, four axes.
Four axes, four jobs
- Fill (
FILL, 0 to 1) turns an outline into a solid shape: the selected tab, the saved bookmark, the bell with something new behind it. - Weight (
wght, 100 to 700) sets the stroke. - Grade (
GRAD, -50 to 200) also changes the stroke, in smaller steps. - Optical size (
opsz, 20 to 48) adjusts the drawing for the size it's shown at.
I read those ranges from the font file, and measured every stroke width below from its outlines.
Weight follows the text
Two fonts from different teams, and the same weight number puts their strokes within an eighth of a pixel.
Some of that is luck. Geist is a fairly light sans, and a heavier text face would need the icon a step heavier. The principle holds either way. When the label changes weight, the icon follows, and nobody has to remember to swap an asset.
Grade follows the theme
Light strokes on a dark background look heavier than the same strokes the other way round. Google's guide suggests a low grade "to reduce glare for a light symbol on a dark background", and gives -25 as the example.
#26221f on #f9f6f3, dark sample #d8d3cf on #181512. Grade -25 thins the stroke from 3.00px to 2.88px: small on one icon, noticeable across a screen of them.I expected grade to be weight without the size change. It isn't. On the menu icon, grade 200 draws exactly the same bar as weight 600. Grade is a second, finer dial: -25 moves the stroke a quarter of the way from weight 400 to 300.
Optical size follows the pixels
A 24px icon scaled up to 48px doubles its stroke to 4px and reads heavy beside large text. At optical size 48 the font draws a 3px stroke instead.
Text fonts get sturdier as they shrink. Material Symbols doesn't. It keeps the stroke near a fixed number of pixels, 1.5 at 20px, 2 at 24px, 3 at 48px, while the icon grows 2.4 times.
Four axes, one token
Put that together and the icon needs almost nothing of its own.
- Weight
the label's font-weight
- Gradea theme token
- Optical size
the font-size
- Filla state token
Google's @font-face rule declares weights 100 to 700, so font-weight drives the weight axis. An icon inside a semibold label inherits 600 the same way the label's text does. font-optical-sizing: auto is the default, and it sets optical size from font-size. That leaves grade for the theme and fill for the state:
:root {
--icon-grade: 0;
}
.dark {
--icon-grade: -25;
}
.icon {
font-family: "Material Symbols Outlined";
font-variation-settings: "GRAD" var(--icon-grade);
}I checked both in Chromium. A bell inside a 600-weight label rendered pixel for pixel like one set to wght 600, and a 48px bell with no optical size set rendered like one set to 48. The common snippet that writes all four axes into font-variation-settings pins weight at 400 and optical size at 24, and switches both of these off.
The type scale already decides weight and size. The icon should read them, not keep a parallel set.
What the font costs
Download size
Every icon, every axis
3,908 KB
- Every icon, one setting
316 KB - 12 icons, every axis
14.9 KB - 12 icons, one setting
2.8 KB
The full font is 3.8 MB, and Google's guide calls loading every axis "usually unnecessary". The default request pins one setting and still sends every icon: 316 KB. Name the icons you use and twelve of them, with all four axes, come to 15 KB.
Inline SVG costs bytes too, and it pays them on every page. The same twelve icons as SVG add 2.3 KB to each page, gzipped, counting the second copy Next.js keeps in the page data. A font downloads once and stays cached. So the question is how many pages someone sees.
KB downloaded, total
Inline SVG adds 2.3 KB on every page. Variable font, 14.9 KB once, costs less from page 7. One-setting font, 2.8 KB once, costs less from page 2.
The one-setting font wins from the second page, and the variable font from the seventh. That's the line I'd draw. A blog visitor reads one or two pages, so SVG wins. Someone who spends the day inside a product opens dozens, and there the variable font pays for itself, with fill transitions thrown in: font-variation-settings animates like any other CSS property.
Two costs don't show up in bytes.
The first is the ligature. You write notifications and the font draws a bell. Until the font arrives, the browser only has the word. Google recommends display=block, which hides it for up to about three seconds. If the font is slower than that, or blocked, the word shows instead: at 24px in a system font, notifications is about 140px wide where a 24px icon should be.
The second is what assistive tech gets. A screen reader sees the word too, and announces "notifications" or "arrow_forward" unless the icon has aria-hidden="true" and its button carries its own label. Browser translation sees a word as well, so the icon also needs translate="no".
Design with the font, ship the shape
There's a third option, and this page uses it. Every bell above came from the Material Symbols font: I set the axes, exported the outline as SVG at build time, and each one is a path of under half a kilobyte. You get the font's drawing at the exact weight, grade and size, with no font file and no ligature.
The axes decide what an icon should look like beside a given word, at a given size, in a given theme. How it reaches the browser is a separate decision, and the page count makes it.