A Font-Like SVG Icon System for Vue

Avatar of Kevin Lee Drum
Kevin Lee Drum on

Managing a custom collection of icons in a Vue app can be challenging at times. An icon font is easy to use, but for customization, you have to rely on third-party font generators, and merge conflicts can be painful to resolve since fonts are binary files.

Using SVG files instead can eliminate those pain points, but how can we ensure they’re just as easy to use while also making it easy to add or remove icons?

Here is what my ideal icon system looks like:

  • To add icons, you just drop them into a designated icons folder. If you no longer need an icon, you simply delete it.
  • To use the rocket.svg icon in a template, the syntax is as simple as <svg-icon icon="rocket" />.
  • The icons can be scaled and colored using the CSS font-size and color properties (just like an icon font).
  • If multiple instances of the same icon appear on the page, the SVG code is not duplicated each time.
  • No webpack config editing is required.

This is what we will build by writing two small, single-file components. There are a few specific requirements for this implementation, though I’m sure many of you wizards out there could rework this system for other frameworks and build tools:

  • webpack: If you used the Vue CLI to scaffold your app, then you’re already using webpack.
  • svg-inline-loader: This allows us to load all of our SVG code and clean up portions we do not want. Go ahead and run npm install svg-inline-loader --save-dev from the terminal to get started.

The SVG sprite component

To meet our requirement of not repeating SVG code for each instance of an icon on the page, we need to build an SVG “sprite.” If you haven’t heard of an SVG sprite before, think of it as a hidden SVG that houses other SVGs. Anywhere we need to display an icon, we can copy it out of the sprite by referencing the id of the icon inside a <use> tag like this:

<svg><use xlink:href="#rocket" /></svg>

That little bit of code is essentially how our <SvgIcon> component will work, but let’s go ahead create the <SvgSprite> component first. Here is the entire SvgSprite.vue file; some of it may seem daunting at first, but I will break it all down.

<!-- SvgSprite.vue -->

<template>
  <svg width="0" height="0" style="display: none;" v-html="$options.svgSprite" />
</template>

<script>
const svgContext = require.context(
  '!svg-inline-loader?' + 
  'removeTags=true' + // remove title tags, etc.
  '&removeSVGTagAttrs=true' + // enable removing attributes
  '&removingTagAttrs=fill' + // remove fill attributes
  '!@/assets/icons', // search this directory
  true, // search subdirectories
  /\w+\.svg$/i // only include SVG files
)
const symbols = svgContext.keys().map(path => {
  // get SVG file content
  const content = svgContext(path)
   // extract icon id from filename
  const id = path.replace(/^\.\/(.*)\.\w+$/, '$1')
  // replace svg tags with symbol tags and id attribute
  return content.replace('<svg', `<symbol id="${id}"`).replace('svg>', 'symbol>')
})
export default {
  name: 'SvgSprite',
  svgSprite: symbols.join('\n'), // concatenate all symbols into $options.svgSprite
}
</script>

In the template, our lone <svg> element has its content bound to $options.svgSprite. In case you’re unfamiliar with $options it contains properties that are directly attached to our Vue component. We could have attached svgSprite to our component’s data, but we don’t really need Vue to set up reactivity for this since our SVG loader is only going to run when our app builds.

In our script, we use require.context to retrieve all of our SVG files and clean them up while we’re at it. We invoke svg-inline-loader and pass it several parameters using syntax that is very similar to query string parameters. I’ve broken these up into multiple lines to make them easier to understand.

const svgContext = require.context(
  '!svg-inline-loader?' + 
  'removeTags=true' + // remove title tags, etc.
  '&removeSVGTagAttrs=true' + // enable removing attributes
  '&removingTagAttrs=fill' + // remove fill attributes
  '!@/assets/icons', // search this directory
  true, // search subdirectories
  /\w+\.svg$/i // only include SVG files
)

What we’re basically doing here is cleaning up the SVG files that live in a specific directory (/assets/icons) so that they’re in good shape to use anywhere we need them.

The removeTags parameter strips out tags that we do not need for our icons, such as title and style. We especially want to remove title tags since those can cause unwanted tooltips. If you would like to preserve any hard-coded styling in your icons, then add removingTags=title as an additional parameter so that only title tags are removed.

We also tell our loader to remove fill attributes, so that we can set our own fill colors with CSS later. It’s possible you will want to retain your fill colors. If that’s the case, then simply remove the removeSVGTagAttrs and removingTagAttrs parameters.

The last loader parameter is the path to our SVG icon folder. We then provide require.context with two more parameters so that it searches subdirectories and only loads SVG files.

In order to nest all of our SVG elements inside our SVG sprite, we have to convert them from <svg> elements into SVG <symbol> elements. This is as simple as changing the tag and giving each one a unique id, which we extract from the filename.

const symbols = svgContext.keys().map(path => {
  // extract icon id from filename
  const id = path.replace(/^\.\/(.*)\.\w+$/, '$1')
  // get SVG file content
  const content = svgContext(path)
  // replace svg tags with symbol tags and id attribute
  return content.replace('<svg', `<symbol id="${id}"`).replace('svg>', 'symbol>')
})

What do we do with this <SvgSprite> component? We place it on our page before any icons that depend on it. I recommend adding it to the top of the App.vue file.

<!-- App.vue -->
<template>
  <div id="app">
    <svg-sprite />
<!-- ... -->

The icon component

Now let’s build the SvgIcon.vue component.

<!-- SvgIcon.vue -->

<template>
  <svg class="icon" :class="{ 'icon-spin': spin }">
    <use :xlink:href="`#${icon}`" />
  </svg>
</template>

<script>
export default {
  name: 'SvgIcon',
  props: {
    icon: {
      type: String,
      required: true,
    },
    spin: {
      type: Boolean,
      default: false,
    },
  },
}
</script>

<style>
svg.icon {
  fill: currentColor;
  height: 1em;
  margin-bottom: 0.125em;
  vertical-align: middle;
  width: 1em;
}
svg.icon-spin {
  animation: icon-spin 2s infinite linear;
}
@keyframes icon-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(359deg);
  }
}
</style>

This component is much simpler. As previously mentioned, we leverage the <use> tag to reference an id inside our sprite. That id comes from our component’s icon prop.

I’ve added a spin prop in there that toggles an .icon-spin class as an optional bit of animation, should we ever need. This could, for example, be useful for a loading spinner icon.

<svg-icon v-if="isLoading" icon="spinner" spin />

Depending on your needs, you may want to add additional props, such as rotate or flip. You could simply add the classes directly to the component without using props if you’d like.

Most of our component’s content is CSS. Other than the spinning animation, most of this is used to make our SVG icon act more like an icon font¹. To align the icons to the text baseline, I’ve found that applying vertical-align: middle, along with a bottom margin of 0.125em, works for most cases. We also set the fill attribute value to currentColor, which allows us to color the icon just like text.

<p style="font-size: 2em; color: red;">
  <svg-icon icon="exclamation-circle" /><!-- This icon will be 2em and red. -->
  Error!
</p>

That’s it!  If you want to use the icon component anywhere in your app without having to import it into every component that needs it, be sure to register the component in your main.js file:

// main.js
import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon.vue'
Vue.component('svg-icon', SvgIcon)
// ...

Final thoughts

Here are a few ideas for improvements, which I intentionally left out to keep this solution approachable:

  • Scale icons that have non-square dimensions to maintain their proportions
  • Inject the SVG sprite into the page without needing an additional component.
  • Make it work with vite, which is a new, fast (and webpack-free) build tool from Vue creator Evan You.
  • Leverage the Vue 3 Composition API.

If you want to quickly take these components for a spin, I’ve created a demo app based on the default vue-cli template. I hope this helps you develop an implementation that fits your app’s needs!


¹ If you’re wondering why we’re using SVG when we want it to behave like an icon font, then check out the classic post that pits the two against one another.