blog

Optimizing SVG icon system in Preact

After this tweet from Jason Miller I jumped at the opportunity to optimize Hugo icon system. Our SVG icons were saved as Preact components and it bothered me that a) each icon was blowing up bundle size and b) even with route splitting in place all icons were ending up in the main bundle.

Using symbols

The first step was extracting all icons from JSX back into separate SVG files. This made it easier to add new icons and optimize using SVGOMG. No need to copy SVG inside component and add { ..props } on <svg> tag.

Next, I needed to move all icons into single SVG and convert to symbol. For that I used svgstore CLI. I added single command to my package.json:

"svg": "svgstore src/images/icons/**/*.svg -o src/images/icons.svg"

Now, running npm run svg will take all icons inside my images/icons dir and output a unified icons.svg file with symbols.

<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="icon-alert" viewBox="0 0 16 16">
<!-- paths go here -->
</symbol>

<!-- other symbols -->
</svg>

Creating icon components

In Preact, my icon components looked like this:

const IconAlert = (props) => (
<svg
aria-hidden={ true }
xmlns="http://www.w3.org/2000/svg"
{ ...props }
>

<use href="#icon-alert" />
</svg>
);

To avoid repeating a similar code block for every icon, I optimized it like so:


const SvgIcon = ({ className, use, ...rest }) => (
<svg
aria-hidden={ true }
className={ classnames(className, use) }
xmlns="http://www.w3.org/2000/svg"
{ ...rest }
>

<use href={ `#${ use }` } />
</svg>
);

export const IconAlert = (props) => <SvgIcon use="icon-alert" { ...props } />;

With SvgIcon I can create new icons by referencing their id from SVG map and I also get CSS class of the same name that I can use for styling.

Load and cache SVG map

Initially, I imported icon map into my main HTML template. This template was used by webpack to generate my main HTML file and was changing on every build and deploy. I wanted to leverage the fact that icons change at a lower frequency than the rest of the product and cache them in the browser. But I also needed a way to serve a fresh map when icons are modified.

Assets pulled through webpack are outputted with hash-based names on build. I could use import to get hashed icon map and then inject it into my HTML. I did that in my root <App /> component:

import icons from '../images/icons.svg';

useEffect(() => {
fetch(icons)
.then((resp) => resp.text())
.then((html) => {
const parser = new DOMParser();
const documentNode = parser.parseFromString(html, 'image/svg+xml');
document.body.appendChild(documentNode.documentElement);
})
.catch((error) => console.log(error));
}, []);

useEffect with empty dependency array will execute just once, on page load, when the app is mounted.

Closing notes

  1. File output from svgstore doesn't have style="display: none;" on svg tag so if you don't wanna SVG map taking up empty space on the page, add body > svg { display: none; } inside <style> tags in document <head>. I prefer <style> tag over external CSS to avoid any flashes and layout reshuffling.

  2. This approach requires setting width and height on every icon. If all icons are the same size this can be easy as setting common .svg-icon class in CSS. If each icon is different (as they are in my case) size can be set either via dedicated CSS classes or by passing width and heigh as props on SvgIcon.