-
Notifications
You must be signed in to change notification settings - Fork 21
SVG icon sprites in Craft CMS with Vite
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.
- 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.
npm install -D @onedarnleyroad/vite-plugin-svg-sprite// 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().
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.svgandbrand/arrow.svgboth become<symbol id="svg-arrow">, but in separate sprite files, and you reference each by its own URL —entry('sprite.svg')#svg-arrowvsentry('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).
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.)
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).
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.
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 --watchEvery save under inputDir rebuilds the sprite, updates the manifest hash, and craft.vite.entry() picks up the new URL on the next request.
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.
- Plugin: https://github.com/onedarnleyroad/vite-plugin-svg-sprite · npm
- nystudio107 Craft Vite: https://nystudio107.com/docs/vite/