Picture an icon sitting inside an <img src="icon.svg"> tag. To the browser, it is a single, sealed picture. You can resize it and move it around, but you cannot reach inside to recolor a path, animate a shape, or give it a hover state. CSS knocks on the door and nobody answers. The classic escape hatch is to paste the raw SVG markup directly into your HTML, but that bloats your pages, breaks browser caching, and turns every icon update into a copy-paste chore.
@iconfu/svg-inject is the polite solution to this very old annoyance. It is a tiny runtime library that finds your <img> elements pointing at SVG files, fetches each file, and replaces the <img> with a real inline <svg> in the DOM. Once the SVG lives inline, every path, circle, gradient, and group becomes a first-class citizen that ordinary CSS can style, animate, and theme. You keep your SVGs as clean, cacheable .svg files, and you get full styling power without a build step, a bundler, or any framework lock-in. The whole thing weighs about 3.5 KB gzipped and ships with zero runtime dependencies.
It is a runtime library through and through. There is no Node.js step, no compilation, nothing to precompute. That makes it a natural fit for WordPress and other CMS-driven sites, server-rendered pages from PHP, Rails, or Django, dynamic content injected at runtime, quick prototypes, and multi-framework projects where you just want one approach that works everywhere.
Why Inline SVG Changes Everything
The core trick is worth dwelling on, because it is the entire reason the library exists. An SVG referenced through an <img> tag is rendered in an isolated context that your page stylesheet cannot pierce. An SVG written inline, on the other hand, is part of the document tree like any <div> or <span>. That means you can write rules like .logo path { fill: var(--brand); } or .icon:hover circle { transform: scale(1.1); } and watch them take effect.
SVGInject gives you the maintainability of external files and the flexibility of inline markup at the same time. The files stay external and cacheable; the inlining happens lazily in the browser only where you ask for it. You get to write CSS the way you always wanted to, including dark-mode variants, transitions, and keyframe animations that target the shapes themselves.
What You Get Out of the Box
Beyond the headline swap, the library quietly handles a pile of details that trip people up when they try to roll their own inliner.
- Accessibility by default. It reads the attributes on your
<img>and translates them into proper ARIA on the resulting<svg>. A meaningfulaltbecomesrole="img"plus anaria-label, an emptyalt=""marks the icon as decorative withrole="none"andaria-hidden="true", and atitleattribute is turned into a real<title>child element. - ID conflict prevention. When several SVGs share internal IDs (extremely common with gradient and clipPath definitions exported from design tools), the library rewrites every ID to be unique and updates all the references in
url(),href,xlink:href, and ARIA attributes to match. No more mysteriously broken gradients. - Smart caching. Each URL is fetched only once and cached for the lifetime of the page. Repeated injections of the same icon reuse the cached string but parse a fresh DOM node each time, so the uniquification and sanitization steps always run.
- Built-in sanitization. Opt in and it strips
<script>,<foreignObject>,on*event handlers, andjavascript:ordata:URIs before injection. - SSR-safe and typed. It is safe to
importin Next.js, Nuxt, SvelteKit, or any server environment because all DOM access is deferred until you actually call it. TypeScript definitions ship in the box.
Getting It Into Your Project
You can grab it from npm with whichever package manager you prefer.
npm install @iconfu/svg-inject
yarn add @iconfu/svg-inject
If you are working without a bundler, you can also drop in the prebuilt file with a plain script tag and skip the install entirely. Either way, the import gives you a single function plus a few helpers attached to it.
import { SVGInject } from "@iconfu/svg-inject";
// Expose it globally so the inline onload handler below can find it.
window.SVGInject = SVGInject;
The One-Line Approach
The fastest possible adoption is a single attribute on an image. When the browser finishes loading the SVG into the <img>, the onload handler fires and hands the element to the library.
<img src="icon.svg" onload="SVGInject(this)" />
That is genuinely all it takes. The <img> is replaced in place by an inline <svg>, and now this works:
img + svg path {
fill: var(--accent, #4f46e5);
transition: fill 0.2s ease;
}
svg:hover path {
fill: tomato;
}
To avoid a brief flash of the unstyled raw image before injection, the library injects a small CSS rule by default that hides injectable images until they are ready. If you run a strict Content Security Policy you can turn that off and provide your own equivalent rule instead.
Doing It From JavaScript Instead
The onload attribute requires script-src 'unsafe-inline', which a tight CSP will forbid. The cleaner approach for those projects is to skip the inline handler and call the function yourself once the DOM is ready. You pass it a single element, an array, or a NodeList, and it returns a Promise that resolves when every injection has finished.
import { SVGInject } from "@iconfu/svg-inject";
document.addEventListener("DOMContentLoaded", () => {
const images = document.querySelectorAll<HTMLImageElement>("img.injectable");
SVGInject(images).then(() => {
console.log("Every icon is now inline and ready for CSS.");
});
});
Wiring It Into a Framework
The library deliberately does not ship React, Vue, or Svelte packages. It is one function, and the maintainers would rather you wire it up in three lines than depend on three separate wrappers. In React, the natural home is the onLoad prop.
import { SVGInject } from "@iconfu/svg-inject";
const inject = (e: React.SyntheticEvent<HTMLImageElement>) =>
SVGInject(e.currentTarget);
export function BrandIcon() {
return <img src="/icons/logo.svg" alt="Acme" onLoad={inject} />;
}
Vue users can register a small custom directive, and Svelte users can write an action that does the same thing. If you find yourself needing loading states, error boundaries, or icons whose src changes dynamically, that is the signal to reach for a richer, React-specific tool like react-inlinesvg instead. SVGInject shines brightest for static SVGs you simply want to paint with CSS.
Reshaping SVGs On Their Way In
The real power surfaces through the lifecycle hooks. They let you intercept and transform an SVG at each stage: beforeLoad can rewrite the URL, afterLoad can mutate the loaded markup, beforeInject can swap in a completely different element, and afterInject runs once the element is live in the DOM. Set defaults globally with setOptions so every injection on the page follows the same rules.
import { SVGInject } from "@iconfu/svg-inject";
SVGInject.setOptions({
makeIdsUnique: true,
afterInject: (img, svg) => {
// Tag the injected element so the rest of your CSS can find it.
svg.classList.add("injected-icon");
},
onFail: (img, status) => {
// status is 'LOAD_FAIL', 'SVG_INVALID', or 'SVG_NOT_SUPPORTED'
console.warn(`Could not inject ${img.src}: ${status}`);
},
});
For graceful degradation, pair injection with an error handler right on the element. If the SVG fails to load, you can fall back to a raster image.
<img
src="icon.svg"
onload="SVGInject(this)"
onerror="SVGInject.err(this, 'fallback.png')"
/>
Keeping Untrusted SVGs Safe
SVG is a rich format, and a malicious file can carry scripts. Inlining anything you do not control is therefore an XSS risk worth taking seriously. The built-in sanitizer covers the most common vectors and is a one-line opt-in, either globally or per call.
import { SVGInject } from "@iconfu/svg-inject";
// Globally, for every injection.
SVGInject.setOptions({ sanitize: true });
// Or just for this batch.
SVGInject(document.querySelectorAll("img.user-upload"), { sanitize: true });
For genuinely untrusted sources such as user uploads or third-party URLs, the recommended pattern is to run the markup through DOMPurify inside the afterLoad hook for comprehensive cleaning. Keep in mind that SVGs are fetched with the browser's fetch(), so the same-origin policy applies and cross-origin files need the appropriate CORS headers on the server.
Running Independent Instances
If different parts of a large app need different defaults, or you want a separate cache that will not collide with the global one, create spins up an isolated instance with its own options and its own cache. The name you pass also becomes the global variable used for onload binding and the flash-prevention selector.
import { SVGInject } from "@iconfu/svg-inject";
const InjectIcons = SVGInject.create("InjectIcons", {
sanitize: true,
copyAttributes: false,
});
InjectIcons(document.querySelectorAll("img.icon"));
A Note on Versions
Version 2 is the current line, and it is a drop-in upgrade from version 1: same API, no code changes required. The upgrade brings bug fixes, the accessibility improvements described above, a full test suite, and CI. The single caveat is that version 2 drops support for Internet Explorer 9 through 11. Modern Chrome, Firefox, Safari, and Edge are all supported, and the one behavioral change to watch for is that decorative images with alt="" now correctly receive role="none" and aria-hidden="true". If you are still obligated to support IE, stay on the 1.x line.
Conclusion
@iconfu/svg-inject solves a problem that has nagged front-end developers for as long as SVG icons have existed: how to keep your graphics in tidy external files while still styling them freely with CSS. It does so with refreshing restraint, a single function, around 3.5 KB, zero dependencies, and no build step, while quietly handling the gnarly parts like ID collisions, accessibility attributes, and optional sanitization. If you have ever wished an icon would just respond to a :hover rule or a CSS variable, this is the library that finally opens the envelope and lets your stylesheet read what is inside.