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
-
File output from
svgstore
doesn't havestyle="display: none;"
onsvg
tag so if you don't wanna SVG map taking up empty space on the page, addbody > svg { display: none; }
inside<style>
tags in document<head>
. I prefer<style>
tag over external CSS to avoid any flashes and layout reshuffling. -
This approach requires setting
width
andheight
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 passingwidth
andheigh
as props onSvgIcon
.