How it works
What happens between
One cache, many references
Every <fast-svg> renders the same tiny template:
<svg class="fast-svg" width="24" height="24" viewBox="0 0 24 24">
<use href="#search"></use>
</svg>
The href points at an <svg id="search"> that lives in a single hidden container appended to <body>:
<div id="svg-cache" style="overflow: hidden; width: 0; height: 0; position: fixed; bottom: -2000px; contain: content; content-visibility: auto;">
<svg id="suspense" viewBox="0 0 24 24">…</svg>
<svg id="search" viewBox="0 0 24 24">…</svg>
<svg id="account" viewBox="0 0 24 24">…</svg>
</div>
A file is fetched and parsed once. A hundred <fast-svg name="search" /> elements share the same cached node, each with its own size and CSS color (currentColor flows through <use>). The container sits off screen, is 0 × 0, and is excluded from style and layout work with contain: content and content-visibility: auto.
Lazy loading, done by the browser
The library does not use an IntersectionObserver. When a component mounts and its icon is not cached yet, it appends a helper image:
<img src="assets/svg-icons/search.svg" loading="lazy" alt="" width="0" height="0"
style="display: none; contain: content; content-visibility: auto;">
- While
display: none, the browser never requests the file. - In the browser, the component switches it to
display: block. From here on the browser's own lazy-loading logic decides when to fetch, which is when the element nears the viewport. - When the image fires
load, the component asks the registry to fetch the same URL. That second request is served from the HTTP cache, so the markup arrives at no extra network cost. - The registry parses the markup, sets its
idto the icon name, appends it to#svg-cache, and the<use href>switches from#suspenseto#search. The helper image is removed.
Until step 4 the component shows the suspense SVG, so the box already has its final size and nothing shifts.
On the server there is no viewport. The component calls the registry directly, so every icon on the page is in the cache when the HTML is serialized. See SSR.
Parsing and the viewBox
The registry parses the fetched string by assigning it to the innerHTML of a detached element and taking the first child. Then it reads the viewBox from the raw text:
const viewBox = svgString.match(/viewBox="([^"]*)"/);
// -> used as the viewBox of every <fast-svg> that references this icon,
// or `0 0 ${defaultSize} ${defaultSize}` when the file has none
Consequences for your SVG files:
- The file should start with the
<svg>element. An XML declaration or a leading comment becomes the "first child" and the icon fails to register. - Write
viewBoxwith double quotes; the first match in the file is used. - Without a
viewBox, a 52 × 71 logo drawn in a0 0 24 24box shows only its top-left corner. Add theviewBoxto the file. The viewBox demo shows both cases side by side. - Internal ids inside the file (
<linearGradient id="a">) are kept as they are. Because all icons share one document, two files that both defineid="a"will resolveurl(#a)to whichever was cached first. Prefix internal ids per file, or export icons with unique ids.
Sizing
size, width and height are written as attributes on the rendered <svg>:
width = width || size
height = height || width || size
size defaults to the defaultSize option ('24'). The rendered <svg> carries margin: 3px and the same contain / content-visibility hints as the cache, so large icon lists stay cheap to lay out.
What this buys you
- One request per distinct icon, no matter how often it is used.
- Requests only for icons near the viewport, using the browser's own scheduler rather than JavaScript observers.
- No layout shift: dimensions are applied before the file arrives.
- Server rendering that ships the icons, with the client hydrating from the DOM instead of refetching.
- 1.5 KB of library code, because the browser does the heavy lifting.
Compare it with other icon libraries in the comparison and the live demo.