IcoMoon CSS deep dive: pseudo-elements, multi-color spans and FOIT-free loading
Every glyph the generator exports is rendered by a CSS pseudo-element: the stylesheet maps a class like .icon-home to a Private Use Area character injected through ::before. That one fact drives everything below — how you size and recolor glyphs, how multi-color icons stack spans, why screen readers still need aria-hidden, and why font-display: block beats swap for icon fonts. New to the tool? Run through the app tutorial first — this guide starts where the export ends.
What the generated stylesheet actually does
Open style.css and you will find exactly two kinds of rules: one shared selector that puts the icon font-family, rendering hints and speak: never on anything matching [class^="icon-"], and one tiny rule per icon setting content to a code point such as \e900 (every code point is listed on our cheat sheet). Because the result is a text character, all of text CSS applies — the official docs cover the generator, but this is the part they leave implicit:
/* Glyphs are text — size and paint them like text */
.btn .icon-download {
font-size: 1.25em; /* tracks the button's own text size */
color: currentColor; /* one rule covers hover + disabled states */
vertical-align: -0.125em; /* optical baseline fix for 1em glyphs */
}
/* Animate the pseudo-element, not the span */
.cta .icon-arrow-right::before {
display: inline-block; /* transforms need a box */
transition: transform 200ms ease-out;
}
.cta:hover .icon-arrow-right::before {
transform: translateX(3px);
}
@media (prefers-reduced-motion: reduce) {
.cta .icon-arrow-right::before { transition: none; }
} Two habits pay off. Size in em, never px, so the glyph scales with whatever text sits next to it. And lean on currentColor instead of writing a color rule per state — hover, focus and disabled styling of the parent then restyles the icon for free. The transform lives on the pseudo-element so the span's layout box never moves.
Multi-color icons: the stacked-span trick
Fonts are single-color, so when you import a multi-color SVG the app splits it into one glyph per color layer at export and emits nested markup: <span class="icon-chart"><span class="path1"></span><span class="path2"></span></span>. Each .pathN::before carries its own code point plus a hard-coded color, and the layers overlap via margin-left: -1em. Fix two things before shipping that: override the baked-in hex colors with your design tokens (.icon-chart .path2::before { color: var(--accent) }), and if the negative-margin stack drifts at fractional browser zoom, switch the wrapper to display: inline-grid with every layer on grid-area: 1 / 1. The same stacking works manually, too — overlap any two single-color glyphs and you have a duotone badge without touching the generator again.
Accessibility: speak: never is not enough
The shared rule ships speak: never (older exports: speak: none) to stop screen readers announcing the raw character. Treat it as a hint, not a contract — the speak property has effectively no screen-reader support, and an unhandled Private Use Area character may be read as silence, "unpronounceable", or worse. The reliable pattern: a decorative glyph gets aria-hidden="true" on its span; an icon-only control gets that plus a real accessible name — visually hidden text inside the button or an aria-label on it. Never rely on the glyph itself to communicate.
font-display, FOIT and layout shift
Standard webfont advice says font-display: swap; for icon fonts that advice is exactly backwards. A swapped fallback renders your code points as tofu boxes or nothing, then the page flashes as real glyphs arrive — and when fallback and icon widths differ, that swap is measurable CLS. block hides the glyph briefly and then paints it correctly: invisible-then-right beats visible-and-wrong. Shrink the blocking window to nearly zero by preloading the WOFF2 — <link rel="preload" as="font" type="font/woff2" crossorigin> — which is easy to justify at 49 KB for the self-hosted free set on our download page:
@font-face {
font-family: "icomoon";
src: url("/fonts/icomoon.woff2") format("woff2"),
url("/fonts/icomoon.woff") format("woff");
font-display: block; /* icons: brief invisibility beats tofu */
unicode-range: U+E900-E9FF; /* fetch only when a glyph is on the page */
}
/* Fixed 1em box = zero layout shift while the font loads */
[class^="icon-"],
[class*=" icon-"] {
display: inline-block;
width: 1em;
height: 1em;
overflow: hidden;
line-height: 1;
} The unicode-range line is the quiet win: pages that render no icons never download the font at all, and pages that do still lay out the fixed 1em box immediately — so the glyph pops in without moving a single pixel around it.
Common pitfalls
- Faux-bold glyphs. Icon fonts ship one weight; a parent's font-weight: bold makes the browser synthesize strokes and the glyph goes blurry. Reset it in the shared icon rule.
- Inherited letter-spacing. Tracking applied to a heading leaks into stacked multi-color layers and shears them apart — set letter-spacing: 0 on icon spans.
- Swapping glyphs via content on hover. It repaints abruptly and confuses assistive tech mid-announcement; toggle a class on the span and let both states keep stable code points instead.
Everything above works identically on the 491 free icons (source on GitHub, © Keyamoon) and on the paid sets — same generated stylesheet, same pseudo-element model. Running out of glyphs? The Ultimate pack is $59 one-time for 1,500+ icons with SVG sources: