Skip to content

SVG icon sprites in Craft CMS with Vite

John D Wells Jr edited this page Jun 27, 2026 · 2 revisions

A guide to handling SVG icons as build-time sprite sheets in a Craft CMS + Vite project: individual .svg files are combined into one (or more) <svg> sprite(s), each emitted with a content-hashed filename and registered in Vite's manifest. Twig templates reference an icon by symbol id, and nystudio107's Craft Vite plugin resolves the hashed URL.

It uses @onedarnleyroad/vite-plugin-svg-sprite (npm) — a zero-dependency, build-only Vite plugin built for exactly this server-rendered, manifest-driven workflow.

Why a sprite?

  • One cached request. A single content-hashed sprite is downloaded once and reused across the whole site, instead of N icon requests or N copies inlined into every page's HTML.
  • No JavaScript. Icons render from a server-rendered <svg><use> reference — nothing to hydrate, works with JS disabled.
  • Cache-busting for free. The filename hash changes only when an icon changes, so the sprite can be served with far-future cache headers.
  • Symbols, not duplication. Each icon becomes a <symbol>; referencing it many times costs one small <use> element each.

Install

npm install -D @onedarnleyroad/vite-plugin-svg-sprite

Configure Vite

// vite.config.js
import { defineConfig } from 'vite'
import svgSprite from '@onedarnleyroad/vite-plugin-svg-sprite'

export default defineConfig({
  build: {
    manifest: true,            // required — the plugin registers sprites in the manifest
  },
  plugins: [
    svgSprite({
      inputDir: 'src/icons',   // wherever your source .svg files live
    }),
  ],
})

build.manifest: true is what lets Craft resolve the hashed sprite URL via craft.vite.entry().

Organise your icons

Drop .svg files into inputDir. Loose files become the root sprite; each subfolder becomes its own sprite:

src/icons/
├── arrow.svg      ─┐
├── close.svg       ├─→ sprite.svg          (manifest key: sprite.svg)
├── menu.svg       ─┘
└── brand/
    ├── logo.svg   ─┐
    └── mark.svg   ─┴─→ sprite-brand.svg    (manifest key: sprite-brand.svg)

Each icon.svg becomes a <symbol id="svg-{filename}"> inside its sprite — so arrow.svg#svg-arrow.

Same filename in two folders? That's fine. arrow.svg and brand/arrow.svg both become <symbol id="svg-arrow">, but in separate sprite files, and you reference each by its own URL — entry('sprite.svg')#svg-arrow vs entry('sprite-brand.svg')#svg-arrow — so they never collide on the page. The ids only repeat across files; the one thing to avoid is inlining multiple sprites into a single document and referencing by bare #svg-arrow.

The plugin does light cleanup of each file (strips the XML prolog, comments, and empty <defs>; namespaces internal IDs so they can't collide) but it doesn't optimise path data — drop in SVGs that are already optimised enough for production. Each source icon needs a viewBox (the plugin strips width/height and the symbol scales from viewBox), and shouldn't carry a hardcoded fill so it can inherit colour from CSS (see Styling).

Reference icons in Twig

The hashed URL comes from the manifest via craft.vite.entry(). Wrap the <svg><use> boilerplate in a macro. Keep the call site clean by making the two things you set most often — the icon name and its classes — positional, and putting everything else (subfolder, accessible label, extra attributes) in an options hash:

{# templates/_macros/icon.twig #}
{##
 # Outputs an SVG icon from the generated sprite sheet via <use>.
 # Add the source SVG to your sprite `inputDir` (root sprite) or a subfolder
 # (e.g. `brand/`) and build with Vite — `arrow.svg` becomes `#svg-arrow`.
 #
 # Sizing: sprite symbols have no intrinsic size and the <use> wrapper has no
 # viewBox, so you MUST size the <svg> via `classList` (e.g. an `.icon` class with
 # width/height). An unsized icon blows up to the ~300x150 default. See Styling.
 #
 # Colour: keep `fill`/`stroke` off the source SVG so the icon inherits colour
 # from CSS; a `fill` on the source lands on the symbol and overrides your styles.
 #
 # Accessibility: icons are decorative by default (aria-hidden + focusable=false).
 # Pass `label` ONLY for a standalone, meaningful icon that isn't already named by
 # adjacent text or a labelled control; it renders role="img" + aria-label.
 #
 # Usage:
 #   {{ icon('arrow', 'icon') }}
 #   {{ icon('logo', 'icon', { folder: 'brand' }) }}
 #   {{ icon('search', 'icon', { label: 'Search' }) }}
 #   {{ icon('star', 'icon', { attrs: { 'data-rating': 5 } }) }}
 #
 # @param string name      - The filename of the SVG, minus extension
 # @param string classList - Classes for the <svg>; must include a size (see Sizing)
 # @param array  options   - { folder, label, attrs }
 #     folder: subfolder sprite name (references `sprite-<folder>.svg`)
 #     label:  accessible name; renders role="img" + aria-label instead of hidden
 #     attrs:  any additional attributes to merge onto the <svg>
 ##}
{% macro icon(name, classList = '', options = {}) %}
  {% set folder = options.folder ?? null %}
  {% set label  = options.label ?? null %}
  {% set file   = folder ? "sprite-#{folder}.svg" : 'sprite.svg' %}
  {% set a11y   = label
    ? { role: 'img', 'aria-label': label }
    : { 'aria-hidden': 'true', focusable: 'false' } %}
  <svg{{ attr({ class: classList }|merge(a11y)|merge(options.attrs ?? {})) }}>
    <use href="{{ craft.vite.entry(file) }}#svg-{{ name }}"></use>
  </svg>
{% endmacro %}
{% import '_macros/icon.twig' as ui %}

{{ ui.icon('arrow', 'icon') }}                          {# decorative (default) #}
{{ ui.icon('logo', 'icon', { folder: 'brand' }) }}      {# subfolder → sprite-brand.svg #}
{{ ui.icon('search', 'icon', { label: 'Search' }) }}    {# meaningful → role="img" + aria-label #}
<button aria-label="Close">{{ ui.icon('close', 'icon') }}</button>

('icon' is a CSS class you define for sizing — see Styling. The macro assumes the plugin's default prefix: 'sprite'; attr() is Craft's attribute renderer.)

Styling

Sizing and colour are left to your CSS (the macro documents the rules in its header). At minimum you need a size — sprite symbols have no intrinsic dimensions and the wrapper has no viewBox, so an unsized icon balloons to ~300×150:

.icon {
  width: 1em;            /* scales with the surrounding text */
  height: 1em;
  fill: currentColor;    /* colour follows the element's `color` */
}

A width alone won't set the height — there's no viewBox on the wrapper to derive it from. For a square icon add aspect-ratio: 1 / 1; for a non-square one, set both dimensions. Because the source SVGs carry no fill, fill: currentColor lets you colour icons with color (or any fill value).

A real-world version (Tailwind)

The macro above is intentionally minimal. Here's a fuller production version from a Tailwind project — it bakes the sizing (aspect-ratio) and colour (fill-*) conventions into the wrapper and documents the gotchas inline. Shown as one complete approach, not because the plugin needs Tailwind (and note it points inputDir at src/sprites/):

{##
 # Outputs an SVG icon from the generated sprite sheet via <use>.
 # Add the source SVG to `src/sprites/` (root sprite) or a subfolder
 # `src/sprites/<folder>/` (e.g. `detail/`), then build via Vite.
 #
 # Colour: the source `<svg>` must NOT carry a `fill` attribute and its paths must
 # not hardcode a colour. Colour comes from a `fill-*` class on the call below
 # (e.g. `fill-current`, `fill-teal`). A `fill` on the symbol breaks this -- the
 # class lives on the outer wrapper and cannot override the inner symbol's fill,
 # so `fill="none"` renders invisible and `fill="currentColor"` ignores `fill-*`.
 #
 # Sizing: sprite symbols carry no intrinsic width/height, and the <use> wrapper
 # has no viewBox, so `h-auto` alone would blow up to the 150px default height.
 # The wrapper therefore sets `aspect-ratio: 1/1` (inline) by default: pass a width
 # and the height follows (square). For a non-square icon, pass an explicit w AND h
 # (e.g. `w-3.5 h-[11px]`) -- when both are set the aspect-ratio is ignored.
 # Always pass at least a width (`w-5 h-5`, `~w-3/4 h-auto`, etc.).
 #
 # Accessibility: icons are decorative by default (aria-hidden + focusable=false).
 # Pass `label` ONLY for a standalone, meaningful icon that is not already named
 # by adjacent text or a labelled control; it renders role="img" + aria-label.
 # For icon-only buttons/links, name the control itself (sr-only text or
 # aria-label) and leave the icon decorative.
 #
 # Usage:
 #   {{ icon('expand') }}
 #   {{ icon('expand_circle_left', 'w-10 h-10') }}
 #   {{ icon('forest', 'fill-teal', { folder: 'detail' }) }}
 #   {{ icon('check_circle', 'w-5 h-5', { label: 'Verified' }) }}
 #   {{ icon('star', 'w-4 h-4', { attrs: { 'data-rating': 5 } }) }}
 #
 # @param string name      - The filename of the SVG, minus extension
 # @param string classList - Classes for the <svg>; must include a size (see above)
 # @param array  options   - { folder, label, attrs }
 #     folder: subfolder sprite name (references `sprite-<folder>.svg`)
 #     label:  accessible name; renders role="img" + aria-label instead of hidden
 #     attrs:  any additional attributes to merge onto the <svg>
 ##}
{% macro icon(name, classList = '', options = {}) %}
    {% apply spaceless %}
        {% set folder = options.folder ?? null %}
        {% set label = options.label ?? null %}
        {% set file = folder ? "sprite-#{folder}.svg" : 'sprite.svg' %}
        {% set a11y = label
            ? { role: 'img', 'aria-label': label }
            : { 'aria-hidden': 'true', focusable: 'false' } %}
        {% set markup %}
            <svg class="block pointer-events-none" style="aspect-ratio: 1 / 1">
                <use href="{{ craft.vite.entry(file) }}#svg-{{ name }}"></use>
            </svg>
        {% endset %}
        {{ markup|attr({ class: classList }|merge(a11y)|merge(options.attrs ?? {})) }}
    {% endapply %}
{% endmacro %}

A couple of notes on the production choices: block removes the inline-baseline gap under the icon, and pointer-events-none makes clicks fall through to the surrounding control instead of landing on the <svg> or a <path> — both sensible defaults for decorative icons.

Dev workflow

The plugin is build-only — it does not run during vite dev. For a live-ish loop, run a build watcher next to your dev server:

vite build --watch

Every save under inputDir rebuilds the sprite, updates the manifest hash, and craft.vite.entry() picks up the new URL on the next request.

Sprites vs. inlining

A common Craft approach is to inline each icon with Twig's svg() function — {{ svg('@webroot/dist/svg/arrow.svg') }} — which drops the full SVG markup into the page on every use. It's simple and request-free, and it lets you style the icon's internals directly. The trade-off: the markup repeats in the HTML each time the icon appears, and there's no cache shared across pages.

A sprite swaps that for a single content-hashed file referenced by <use> — far fewer bytes per use and one cross-page cache, at the cost of an external reference and the build step.

Rule of thumb: reach for a sprite when the same icons appear a lot across the site; inlining is fine for one-offs, or for icons whose internals you need to target with CSS.

Reference

Clone this wiki locally