Efficient SVG icons in Web Components with Webpack and SVGO

So many ways to load them

There are a lot of different ways to show an SVG on a webpage: <img>, <embed>, <object>, <iframe>, <canvas> and <svg> among them. I think for any halfway modern browser there are really only two serious contenders here. Referencing an SVG file:

<img src="image.svg" />

And embedding the SVG directly into the DOM.

<svg><circle cx="50" cy="50" r="40" /></svg>

There are some advantages and drawbacks to each. <img> tags will keep your layouts small and only download the image as it’s needed. It can be cached by the browser and doesn’t need to be re-downloaded when your JS bundles update. But you can’t change their fill with CSS rules, which makes them not ideal for icons, which often need to switch color in dark vs light mode or for high contrast mode.

Conversely, <svg> tags are malleable to CSS rules, but they are harder to bring in, cache separately and lazy-load.

In our case we support dark and light modes, so we went with <svg> tags for our icons.

Should we delay-load?

Now the question is, how do we include them from our Web Component templates? In React we have SVGR, to turn SVGs into React components that can do fancy stuff like tell Webpack to make a separate bundle with the SVG data and go load that in on-demand.

There is not yet a tool that can do exactly that for Web Components. I was preparing to sit down and write one, but after thinking about it for a bit and consulting with some members of the FAST Element team; I don’t think it’s necessary to load them separately.

  • Properly optimized icon SVGs are tiny, since they are just vector data. Not a lot is being gained by pushing that off to a separate web request and running it through a bunch of code to bring it in.
  • The icon data is slim enough that it will represent a small fraction of the template size.
  • Bundled icons never have late pop-in and are never missing. They are treated like just another part of your view template.

How to include them in your template

Putting them straight into the template is one way:

<button><svg><circle cx="50" cy="50" r="40" /></svg></button>

That’s not great because you can’t re-use them, and if you need to update an icon you have to go find everywhere you used it.

(fun fact: you only need the xmlns attribute on the <svg> tag when it’s in a file and you can omit it when it’s embedded in the DOM)

The next impulse is to just export them from some icons module:

export const circleIcon = `<svg><circle cx="50" cy="50" r="40" /></svg>`;

Then import the string and include in your template:

<button>${circleIcon}</button>

That was what we tried at first. It worked, but it had some drawbacks. Our central icons file soon got quite large, up to 220kb of icons from various things that might eventually be shown at some point in interacting with the app. But we only need ~10kb of that for the first page load. The initial assumption was that Webpack would save us, but it just ended up putting the whole giant module in a critical JS bundle. That means we’d need to front load every single icon before we could display anything at all. Oops.

The solution

We ended up using a combination of raw-loader and svgo-loader webpack modules. In our webpack config:

module: {
    rules: [
        { test: /\.svg$/, use: [
            { loader: "raw-loader" },
            {
                loader: "svgo-loader",
                options: {
                    configFile: false,
                    floatPrecision: 2,
                    plugins: extendDefaultPlugins([
                        "removeXMLNS", // We can safely remove the XMLNS attribute because we are inlining our SVGs
                        {
                            name: "removeViewBox",
                            active: false
                        }
                    ])
                }
            }
        ]},
        ...

Raw-loader means it’s going to bring the string directly into the module instead of reference it somewhere else.

The SVGO-loader performs a lot of optimizations on the SVGs to slim them down: for example removing redundant paths and combining elements. So you end up with:

import circleIcon from "./Circle.svg";

...

<button>${circleIcon}</button>

The built JS file looks like this:

<button>${'<svg><circle cx="50" cy="50" r="40" /></svg>'}</button>

Except a bit smaller and more efficient than what was in Circle.svg.

De-duplication

A concern here is the size penalty from including the same icon multiple times. One thing to note is that Webpack will, even with the raw-loader re-use a variable if you’ve used the same SVG more than one time in a single module.

Another technique you can use is that if you have icons that are re-used and that show up together, is to re-export them from a shared icons module, and import them where needed:

import circleIcon from "./Circle.svg";
import squareIcon from "./Square.svg";

export { circleIcon, squareIcon };

Then you can import and use them as normal strings. Just remember that if you put everything in the same file it won’t scale very well as Webpack can’t break the module up.

TypeScript ANGRY

If you’re using TypeScript (which of course you should be) it’s going to be cross with you. Cannot find module './Circle.svg' or its corresponding type declarations. In other words “What are you doing importing an .svg file? That doesn’t look like an ES6 module.”

We need to tell it “Shhh, shhh, it’s OK. My buddy Webpack is going to come by and fix everything. When he’s done, it will look like you’re importing a string, so don’t worry about it.” Translated into TypeScript:

declare module "*.svg" {
    const content: string;
    export default content;
}

Then it says “OHHHH got it, When I see a module ending with .svg, I can assume it’s going to have a default export with a type of string.”

Put this type declaration in a package that all the icon users will import from.

Icon package

I also made an icon package with all of the .svg files in it:

In package.json you can tell it to include the icons folder:

Then you can import from “package-name/icons/Circle.svg”

The result

After we implemented this icon system, we dropped our critical bundle size by ~210KB, which improved our PLT1 by 100ms.

It’s also easy to add SVGs as you just check them into the repository and import them as strings, which have been automatically streamlined in the Webpack step.

Leave a Reply

Your email address will not be published. Required fields are marked *