How I added svg icons to my vue component library

How I added svg icons to my vue component library
Photo by Harpal Singh / Unsplash

There are different ways how you might add icons to your application or website, the most common thing when I started doing frontend development was using an icon font like fontawesome.

Webfonts

The way these work is by having an html element with a class, this class gets a special glyph as content via css and this glyph is then displayed as the icon because of the font.

<i class="fas fa-example"/>
.fa-example:before {
    content: "\f095";
}

Pros

  • super easy to set up and use
  • reliable technology
  • can be used in css via pseudo-elements
  • works for content added after page load automatically

Cons: File size.

  • the all.min.css file which includes the core library and all sets (solid, brands, regular) is 101KB
  • brands woff2 webfont: 116KB
  • regular woff2 webfont: 25KB
  • solid woff2 webfont: 153KB

So it can happen quite fast that the browser has to load 400KB just for icons. Fontawesome offers ways to reduce this of course (not loading all sets, creating a "kit" with just the icons you need, and so on)

Why does filesize matter?

  • Load time: the slower the internet connection the longer stuff loads, this can also cause an unpleasant flash of invisible text (FOIT) or flash of unstyled text (FOUT) where everything gets re-arranged because the icons have loaded in
  • Mobile users: many mobile users have limited data plans
  • SEO: search engines factor page speed into their ranking which includes the size of the loaded assets

My honest opinion about this is: yes, initial load time does matter, but 400KB are just 400KB. At the average mobile download speed worldwide of 50 Mbps (according to statista january 2024) this would mean 0.064 seconds.

Most websites are plastered with unnecessary images and just one or two more pictures on the website put the 400KB in the shade again. So to be fully honest, filesize is not my primary concern.

What about custom icons?

This is where it gets tricky. Creating a custom icon webfont is not straight-forward. There are npm packages and online services like icomoon that convert svg files to an icon font. I gathered a bit of experience doing this (using icomoon and even writing my own webfont service) and my experience with it was as follows:

  • adding new icons requires regenerating and replacing all icon assets (css and webfonts) so you have to upload the icons, generate the new bundle, extract the zip archive, drag the files into the project
    • also, if you use an online service, the icons are not part of your project which can suck
    • an alternative is a neat CLI tool like FontCustom
  • some svg paths cannot be converted to webfont glyphs because of the fill-rule. To put it simply: paths that cross over themselves or some cutouts simply do not work as font glyph.
  • some browser aggressively cache fonts, so you have to use cache busting methods like hash in the file name but that is not too hard

SVG icons

Adding icons as SVGs brings exciting possibilities

  • font icons sometimes just do not render as good and crisp as an SVG
  • you can dynamically customize the stroke-width to have thin icons
  • masking, layering, transforms

But for svg icons to work you need js. It basically works like the webfont example:

<i class="fas fa-example"/>

but now the js detects the element and adds the svg into it.

Storing the full svg image of each icon would be wasteful so libraries like font-awesome compress each svg icon into one single svg path element and just store the path data.

Vuejs

While this works with vanilla js, it requires querying the document, having mutation observers to detect newly added icons and so on, in vue you just have an icon component.

<script setup lang="ts">
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { faTwitter } from '@fortawesome/free-brands-svg-icons'

library.add(fas, faTwitter)
<script>

<script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
<script>

<template>
  <FontAwesomeIcon icon="fa-solid fa-check-square" />
  <FontAwesomeIcon icon="fa-regular fa-coffee" />
</template>

In this example the whole solid set is added globally and the component just receives the name, like the css-class of the webfont variant. It also allows adding single icons if you want to reduce the bundle size and just include the icons you actually need.

As I already explained just having all icons is great for the dev experience and adding every icon manually once you need it and keeping track of icons you have added in the past but no longer use is just not worth the hassle in my opinion.

Let's get custom

Okay so how did I implement this in my component library?

Generating the icon list

I wrote a command that reads all icon svg files in a folder and performs some optimizations:

  • run the svg through svgo
    • svgo preset which drastically reduces the svg filesize and joins everything to one path if possible
    • remove unnecessary attributes
    • convert styles to attributes
  • store the svg as an array of its elements
    • is the element fill or stroke?
    • what type is the element?
      • store the element type as a number that corresponds to its index in a lookup-array
    • the elements attributes
      • long attributes like "stroke-linejoin" are aliased --> "sj"
glob(iconpath + "/*.svg").then((files) => {
    let current = 0;
    let out = {};

    async function next() {
        let f = files[current];
        let icon = await optimizeSvg(f);
        let key = f.match(/([^/\\]+)\.svg$/)[1];

        out[key] = iconToObject(icon);

        if (current < files.length - 1) {
            current++;
            next();
        } else {
            let str = "export default " + JSON.stringify(out);
            fs.writeFile(out_path, str, () => {
                resolve(Object.keys(out));
            });
        }
    }

    next();
})

the command code

export function iconToObject(icon) {
    let mainelem = xmljs.xml2js(icon).elements[0];
    if (mainelem.name !== "svg") throw new Error("Invalid svg");

    let elements = mainelem.elements.map(e => {
        if (e.elements) throw new Error("Nested elements are not supported");

        let res = [];

        // ==== fill ====
        if (e.attributes.fill === "none" || typeof e.attributes.stroke !== "undefined") {
            res.push(1); //stroke
        } else {
            res.push(0); //fill
        }

        // ==== element ====
        let type = ELEMENT_TYPES.indexOf(e.name);
        if (type === -1) throw new Error("Unknown type " + e.name);
        res.push(type);

        // ==== attributes ====

        delete e.attributes.stroke;
        delete e.attributes.fill;
        delete e.attributes["stroke-width"];

        //copy attributes from main element over to children if it does not exist there
        for (const key in ATTR_ALIAS_REVERSE) {
            if (typeof mainelem.attributes[key] !== "undefined" && typeof e.attributes[key] === "undefined") {
                e.attributes[key] = mainelem.attributes[key];
            }
        }

        for (const key in ATTR_ALIAS_REVERSE) {
            if (typeof e.attributes[key] !== "undefined") {
                let alias = ATTR_ALIAS_REVERSE[key];
                e.attributes[alias] = e.attributes[key];
                delete e.attributes[key];
            }
        }

        res.push(e.attributes);

        return res;
    });

    return elements;
}

converting an svg to an array

So one icon looks like this:

{
  ...
  "person": [
          [
              1,
              0,
              {
                  "d": "M344 144c-3.92 52.87-44 96-88 96s-84.15-43.12-88-96c-4-55 35-96 88-96s92 42 88 96",
                  "sj": "round",
                  "sc": "round"
              }
          ],
          [
              1,
              0,
              {
                  "d": "M256 304c-87 0-175.3 48-191.64 138.6C62.39 453.52 68.57 464 80 464h352c11.44 0 17.62-10.48 15.65-21.4C431.3 352 343 304 256 304Z"
              }
          ]
  ],
  ...
}

Attributes on the svg element itself are removed or moved to the child elements, this way I only store what is really necessary.

The icon component then just "unpacks" this information by re-building the svg tags with its attributes.

Comparison to fontawesome

So lets do a little comparison to finish up

  • fontawesome css+webfont
    • 101KB all.css + (25kb regular + 116kb brands + 153kb solid) woff2
    • 395KB for 2000 icons -> ~200 bytes per icon
  • fontawesome js + svg
    • 1455KB all.min.js for 2000 icons -> ~728 bytes per icon
  • my custom svg icons
    • 172KB for 460 icons -> ~420 bytes per icon

I think thats good enough for now, even If I only have a limited featureset.

A big difference to fontawesome is that fa only stores one icon path, because they have pre-optimised their svgs when designing them, so that each icon can be compressed into one path. Svgo is not able to do this for every icon, but the advantage for me is: I can just throw any svg I want into my library and not worry about it.

Fontawesome also includes many cool features like masking, stacking, transforms which I do not have implement yet

A big part of a good developer experience is actually finding the icons you need. Just showing a big list of icons sucks, because you have to search through the whole list. For a long time, this was how the official Google Material Icons page worked, and others had to build custom pages to add a proper search.

The general approach is to add keywords to each icon, so that you can find a "user" icon even if it is called "person", or a "plus" icon if you search for "add".

Doing this manually is a lot of work, so I wrote a script that asks chatgpt to generate a list of keywords for a given icon name.

    "mic-off-circle": ["microphone","audio","sound","record","speak","voice","talk","communication"],
    "moon": ["night","lunar","celestial","lunar","phase","nighttime","dark","astronomy"],

This is not pure gold as the icon name itself is added to the keywords and some keywords are duplicated. Also, the AI only gets the icon name, not the image, so if an icon is called "watch", it does not know whether the icon shows eyes or a clock.