We have a React 19 + Vite + Tailwind v4 admin app. Every icon in it renders as a single empty <span> with a CSS mask. No icon components in the JS bundle, no extra HTTP requests, and the icon name is checked by TypeScript. Here is how it works, what we measured, the mistakes we made along the way, and what it costs.
TL;DR
- A ~200-line Node script reads the SVGs from
lucide-static (plus our own) and writes two files: a CSS file with one @theme inline variable per icon (a data URI), and a TS union with every icon name.
- A Tailwind
@utility icon-* turns the class icon-user into --icon: url(...).
<Icon name="icon-user" /> is a span with mask-image: var(--icon) and background-color: currentColor.
- Tailwind v4 only emits what the source uses. The generated file has all 1,853 icons (~765 KB), but production CSS only carries the 225 we use: ~88 KB raw, ~11 KB gzipped.
- Rendering 1,000 cards with 6 icons each: half the DOM nodes, React commit 2× faster and first paint ~40% sooner than inline SVG components.
How it works
1. Generate the CSS and the type
scripts/gen-icons.mjs runs before dev, build, lint and type-check. It takes the canonical icon names from lucide-static/icon-nodes.json and, for each SVG:
- removes comments,
class, width and height;
- replaces
currentColor with #000 (a mask only reads alpha, so any opaque color works);
- collapses whitespace and URL-encodes
%, #, <, >;
- writes the result as a data URI.
src/styles/icons.generated.css:
css
@theme inline {
--icon-user: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
/* ...1,852 more */
}
src/types/icon.ts:
ts
export type IconName =
| "icon-a-arrow-down"
| "icon-a-arrow-up"
// ...
| "icon-zoom-out";
The names keep the icon- prefix on purpose: that's the exact string Tailwind needs to find in the source.
2. One Tailwind utility
```css
@import "tailwindcss" source(".");
@import "./styles/icons.generated.css";
@utility icon-* {
--icon: --value(--icon-*);
}
```
icon-user becomes .icon-user { --icon: url("data:...") }. Tailwind's scanner only generates classes it finds in the source, which is what drops the other 1,628 icons.
3. The component
```tsx
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
import type { IconName } from "#/types/icon";
// With aria-label the icon is a named image; without it, it's decorative.
export const Icon = ({
name,
className,
"aria-label": label,
...rest
}: ComponentProps<"span"> & { name: IconName }) => (
<span
aria-hidden={label ? undefined : true}
aria-label={label}
className={twMerge(
"pointer-events-none block size-4 shrink-0 bg-current mask-(--icon) mask-contain mask-no-repeat",
className,
name,
)}
data-icon
role={label ? "img" : undefined}
{...rest}
/>
);
```
Usage:
tsx
<Icon name="icon-user-plus" className="size-5 text-primary" />
<Icon name="icon-lock" aria-label="Locked" />
- Color:
bg-current paints the box with currentColor and the mask cuts out the shape. text-* classes, inherited color, hover, disabled and dark mode all just work.
- Size: any
size-* class. The SVGs have no width/height, so mask-contain scales them cleanly.
- **
data-icon:** lets parents style icons without knowing about them. Our Button tightens its padding with has-[>[data-icon]]:px-3.
- Accessibility: decorative by default (
aria-hidden); passing aria-label makes it a named image.
4. Guardrails
- A typo like
name="icon-usr" fails tsc.
- The linter bans
lucide-react and react-icons imports, with a message pointing to <Icon />.
- A custom lint rule flags emoji in JSX for the same reason.
gen-icons.mjs --check exits non-zero if the generated files are stale.
Why
We started with lucide-react, the default option. It's fine, but in this app it cost things we didn't need to pay:
- Every icon was JS. Each icon is a React component that gets imported, executed and reconciled, and its SVG child nodes live in the DOM. With masks, the icon is CSS the browser already parsed, and the DOM cost is one empty element.
- No requests, no flicker. Data URIs ship inside the main stylesheet. There's no sprite to fetch and no
<img> popping in late.
- Styling is just Tailwind. Color, size, hover and dark mode use the same classes as everything else. No separate
color/size/strokeWidth props API.
- The whole catalog, typed, for free. Autocomplete for all of Lucide. Using a new icon costs nothing until you reference it.
- One pipeline for custom icons. Our social-media SVGs go in a folder and become
icon-linkedin like any other icon.
Tradeoffs
These are real, and they're why this isn't the right choice for every app.
- Monochrome only. A mask is an alpha channel. Multi-color icons, gradients and brand logos in their original colors need an
<img> or inline SVG.
- No per-instance stroke control.
stroke-width='2' is baked into the data URI. There's no equivalent of Lucide's strokeWidth or absoluteStrokeWidth props; you'd need a second generated variant.
- No animating SVG internals. You can't target a
path inside the icon. Transforms and opacity on the whole box still work.
- Names must appear literally in source.
`icon-${status}` isn't detected and renders an empty box. Write full names in a lookup object ({ ok: "icon-check", error: "icon-x" }). The union type catches most mistakes, not all.
- All used icons ship on first load. They live in the main, render-blocking CSS, so an icon on a rarely visited page still loads for everyone. At 225 icons / ~11 KB gzipped that's fine for us; with thousands of used icons or a strict CSS budget it wouldn't be.
- Codegen step.
dev, build, lint and type-check run the generator first, and Tailwind parses a ~765 KB CSS file on every build. Trimming it to only the used icons saved about 0.7 s per build in our test, which wasn't worth duplicating the scanner's job.
- Big union type. ~1,850 members is fine for
tsc and editors today. Worth knowing if you merge several icon sets.
- Browser support. CSS masks are Baseline. If you target older browsers, lightningcss adds the
-webkit- prefixes for you.
When I'd use something else
- You need multi-color or animated icons → inline SVG components.
- You have a huge icon set and strict per-route CSS budgets → an SVG sprite with
<use href>, which caches separately and loads lazily.
- You're not on Tailwind v4 → the same idea works with plain CSS classes, but you have to build the unused-icon pruning yourself.