How svgworm works

svgworm is a build step, not a runtime. It reads a folder of SVG files and writes four kinds of files. The Icon component only reads them. Knowing what each file is for makes a blank icon or a wrong color easy to place.

icons/               svgworm              public/icons/icons.<hash>.svg        the sprite
  home.svg       ─────────────▶           public/icons/icons.stack.<hash>.svg  the stack (only when needed)
  brand/sunset.svg                        svgworm/icons.js + icons.d.ts        the manifest and its types
                                          svgworm/manifest.json                the manifest for scripts

The sprite#

The sprite is one SVG file with a <symbol id="i-home" viewBox="0 0 24 24"> per icon. The page references an icon with <svg><use href="/icons/icons.<hash>.svg#i-home"/></svg>. The browser fetches the sprite once, caches it, and draws the symbol inside each <use>. No icon code ships as JavaScript, which is the whole point (ADR-0001).

The sprite is an external file rather than an inline <svg> in the HTML. That way it is cached across pages and keeps the HTML small. The cost is one extra request on the first visit, and the whole set downloads at once (performance notes).

The file name carries the first 8 hex characters of the SHA-256 of its bytes. So it can be served with a year-long cache and still update the moment its content changes.

Before an icon goes into the sprite, svgworm does five things:

  • Validates it: no scripts, no event handlers, no references to other files.
  • Inlines <style> rules into attributes, so one icon’s CSS can never reach another.
  • Optimizes it with svgo using a fixed configuration.
  • Prefixes every internal id (gradients, masks, clip paths) with the symbol id, so two icons never collide.
  • Drops comments, except license notices (<!-- @license … --> and <!--! … -->, as Lucide, Tabler and Font Awesome write them). The sprite keeps each notice once before its root element, so an ISC or MIT notice travels with the copies.

Color#

Each icon is classified on its own (ADR-0004):

  • mono: every fill and stroke is one color, or currentColor. svgworm rewrites those paints to currentColor, so the icon takes the color of the text around it.
  • multicolor: the icon uses several colors, a gradient with several stops, a raster image, a pattern, a CSS variable or an animated color. It keeps what the file says.

To theme a multicolor icon, write CSS variables into the SVG: fill="var(--brand, #2196f3)". svgworm keeps them as they are. A page sets --brand on the icon or any parent, and the fallback applies when it is unset. This works through the external sprite in Chromium, Firefox and WebKit.

It does not reach an icon that also uses gradients, masks or other url(#…) references. Those are drawn as <img>, which page CSS cannot style, so they show the fallbacks. svgworm never generates variables itself (ADR-0026).

The guess is right for most icon sets. When it is not, a color override forces mono or preserve per icon. The manifest records the result as color.

Delivery#

Some SVG features do not render through an external <use> in WebKit. These are anything referenced with url(#…): gradients, masks, clip paths, filters and markers (ADR-0006, checked in Chromium, Firefox and WebKit).

svgworm does not drop those features or inline every icon. It puts such icons in a second file, the stack, and records per icon how to show it. That is the delivery field in the manifest:

delivery When What Icon renders Follows color?
use No url(#…) reference (most icons) <svg><use href="sprite#id"/></svg> mono: yes
img Needs the stack and is multicolor <img src="stack#id"> no
mask Needs the stack and is mono <span style="mask: url(stack#id); background: currentColor"> yes

The stack file is an “SVG stack”: every icon is a nested <svg id="…"> hidden by a :target rule, and the URL fragment shows exactly one. It is written only when an icon needs it. The Icon reference shows the exact markup of each form.

The manifest and the types#

svgworm/icons.js exports one object, manifest. It holds the sprite and stack URLs and one entry per icon: sprite (the group), id, viewBox, color and delivery. It imports nothing and holds no SVG markup, which is why it is safe on the server and cheap to import.

svgworm/manifest.json is the same object for templates and scripts. svgworm check compares it with a fresh build.

svgworm/icons.d.ts declares IconName, the union of every icon name, and the manifest’s shape. createIcon(manifest) and Icon.astro take name: IconName. So a name that is not in the set fails tsc or astro check, and the editor completes the valid ones. The union stays fast at every size: 20,000 names type-check in 0.14 s (ADR-0014).

The exact shape of these files is the output contract, a public API. The adapters read nothing else, and changes to it come with an ADR and a changelog entry.

Names#

An icon’s name is a pure function of its path inside the source folder (ADR-0007): icons/brand/sunset.svg is brand/sunset. The rules:

  • Folders become namespaces, unless flatten is on.
  • Each segment is lower-cased and Unicode-normalized.
  • Every run of spaces, _, . or other punctuation becomes one -.
  • Accented and non-Latin names stay as they are (café), so they work as TypeScript string literals.
  • Letter case is never split: ArrowLeft.svg is arrowleft. The build prints a name/case-folded warning that suggests renaming it to arrow-left.svg (ADR-0021).

The symbol id in the sprite is derived from the name: i- plus the name with / as -- and any non-ASCII character as _<hex>_ (i-brand--sunset, i-caf_e9_). It is always a valid HTML id, CSS identifier and URL fragment.

Two files that produce the same name are an error that lists both. svgworm never renames or numbers anything silently.

Deterministic output#

The same input gives byte-identical output on every OS and every run. How:

  • Sorting: files are sorted by code point before anything is written.
  • Normalization: path separators and Unicode normalization are unified.
  • Hashes: they come from content only.
  • No ambient input: nothing reads the clock, the locale or an absolute path.

CI builds the fixture set on Linux, macOS and Windows and compares the fingerprints.

The practical effect: committed output never changes without an icon changing. So svgworm check is a reliable gate, and a review diff shows exactly what an icon change did.

Where to read more#

  • ADRs: the decisions above with their evidence, including the browser matrices.
  • Comparison: how this model differs from per-icon components (SVGR) and from bundler plugins.
  • Troubleshooting: the symptoms this model explains.

Edit this page on GitHub