February 9, 2020

Creating an SVG Icon Component in Vue

When I built GiftyDuck I wanted to use something other than Font Awesome for a change, so I settled on the beautiful Streamline Icons set which has an awesome variety of icons and variations. Unfortunately, there was no Vue component version like vue-fontawesome, so I had to make my own.

When using SVGs icons in an SPA, I believe it's almost essential to place the SVG inline on the page so that they are displayed instantly, rather than requiring a separate network request.

I have been reflecting on the approach I used and would like to document it as well as some alternative approaches I quite like.

The goal

<BaseIcon name=”user” class="text-xl text-blue-500" />

Features

  • The height and width is 1em so that it's sized appropriate to surrounding text (like FontAwesome).
  • The color (stroke or fill depending on the icon set) is set to currentColor so that it's coloured appropriate to the surrounding text (also like FontAwesome).
  • Tailwind utilities can be used to specify color and size (and stroke width if applicable for the icon set).

In terms of getting the SVGs actually into the component, there are probably three decent approaches I can think of...

Approach 1 — Manually paste each SVG into its own Vue component that can be loaded into the BaseIcon component

Structure

components/
  - BaseIcon.vue
  - icons/
    - HomeIcon.vue
    - UserIcon.vue
    - [...]

Icon Example

<template>
    <svg>...</svg>
</template>

The BaseIcon component

<template>
  <component
    :is="iconComponent"
    role="img"
    class="inline-block fill-current"
    style="height: 1em; width: 1em;"
  />
</template>

<script>
const icons = {
  home: require('./icons/HomeIcon.vue'),
  user: require('./icons/UserIcon.vue'),
  // ...
}

export default {
  props: {
    name: {
      type: String,
      required: true,
      validator(value) {
        return Object.prototype.hasOwnProperty.call(icons, value)
      }
    }
  },

  computed: {
    iconComponent() {
      return icons[this.name]
    }
  }
}
</script>

Note that the icons I'm using in this example are using fill and not stroke, so I've applied the fill-current class. If your icons are using stroke, then you'll want to use stroke-current instead.

Pros

  • Simplest approach
  • No need to mess around with webpack and loaders
  • Doesn't interfere with any existing loaders applied to SVG files

Cons

  • Manual process required to create the Vue component for each icon
  • The files can only be used within Vue, and not in other places like PDFs, emails, etc.

Optionally import the icon components automatically

Instead of manually populating the icons object, you could loop over the icons on the filesystem and populate it automatically.

import kebabCase from 'lodash/kebabCase'

const icons = {}

const requireComponents = require.context('./icons/', false, /[\w]+Icon\.vue$/)

requireComponents.keys().forEach(fileName => {
  const iconName = kebabCase(fileName.replace(/^\.\/(.+)Icon\.vue/, '$1'))
  const componentConfig = requireComponents(fileName)
  icons[iconName] = componentConfig.default || componentConfig
})

Approach 2 — Store pure SVG files in the repo and automatically create Vue components from them using vue-svg-loader

vue-svg-loader is a handy webpack loader that will allow you to import an SVG and automatically convert it into a Vue component using vue-template-compiler.

Install the plugin following the instructions in the README, making sure to remove any existing loaders for SVG files.

Structure

components/
  - BaseIcon.vue
assets/
  - icons/
    - icon-home.svg
    - icon-user.svg
    - [...]

The BaseIcon component

<template>
  <component
    :is="iconComponent"
    role="img"
    class="inline-block fill-current"
    style="height: 1em; width: 1em;"
  />
</template>

<script>
const icons = {
  home: require('../assets/icons/icon-home.vue'),
  user: require('../assets/icons/icon-user.vue'),
  // ...
}

export default {
  props: {
    name: {
      type: String,
      required: true,
      validator(value) {
        return Object.prototype.hasOwnProperty.call(icons, value)
      }
    }
  },

  computed: {
    iconComponent() {
      return icons[this.name]
    }
  }
}
</script>

This component is almost identical to the first approach, with the only difference being that we're able to directly require the .svg files rather than needing to manually wrap them in their own component.

Pros

  • Pure SVG files are maintained in the repo and can be used outside of Vue context (e.g. on static HTML pages, PDFs, emails, etc.)

Cons

  • Webpack configuration is required for Vue CLI (and Storybook if you're using it) to remove the existing loader for .svg files, before then configuring vue-svg-loader to handle them instead. This is convered in their README.
  • SVG images will no longer be handled by file-loader so using them with <img src="..."> will not work.
  • vue-svg-loader does not appear to be very widely used. But it is a pretty simple plugin that uses the official vue-template-compiler under the hood so I don't see this as a big deal.

Optionally import the icon components automatically

As with the first approach, we also have the option of automatically populating the icons object by looping over the icons on the filesystem. The only difference is where the icons are stored and how we determine the icon name from the file name.

const icons = {}

const requireComponents = require.context('../assets/icons/', false, /.svg$/)

requireComponents.keys().forEach(fileName => {
  const iconName = fileName.replace(/^\.\/icon-(.+)\.svg$/, '$1')
  const componentConfig = requireComponents(fileName)
  icons[iconName] = componentConfig.default || componentConfig
})

You can see a full example of this approach at https://github.com/jessarcher/icon-component-vue-svg-loader


Approach 3 — Store pure SVG files in the repo and pull the contents of the SVG into the component using html-loader and the v-html directive.

Instead of creating a Vue component for each SVG, we could instead just load the SVG contents into the page using Vue's v-html directive and the html-loader for webpack.

Webpack config (vue.config.js)

module.exports = {
  chainWebpack: (config) => {
    const svgRule = config.module.rule('svg');

    svgRule.uses.clear();

    svgRule
      .use('html-loader')
      .loader('html-loader')
  },
};

If you're not using Vue CLI then you might need to do some different webpack sourcery (sic). Basically you need to remove any existing loaders for SVG files and use html-loader instead. See here for an example for Storybook.

The BaseIcon component

<template>
  <div
    v-html="require(`../assets/icons/icon-${name}.svg`)"
    class="inline-block fill-current"
    style="height: 1em; width: 1em; vertical-align: -0.125em;"
  />
</template>

<script>
export default {
  props: {
    name: {
      type: String,
      required: true,
    }
  }
}
</script>

Pros

  • As above, the raw SVGs are available in the repo for use in other contexts.
  • The contents of the SVG are inserted as plain HTML rather than being converted into a Vue template, which probably removes some unnecessary overhead from the other approaches.
  • There is no need to import each icon

Cons

  • Similar webpack configuration grossness required as approach 2.
  • As with approach 2, SVG images will no longer be handled by file-loader so using them with <img src="..."> will not work.
  • A wrapper element (e.g. <div>) is required to house the HTML.

You can see a full example of this approach at https://github.com/jessarcher/icon-component-v-html


Other considerations

  • As mentioned above, you will need to choose between stroke-current and fill-current depending on your icon set.
  • If you're using stroke-based icons then you can take advantage of Tailwind 1.2's new stroke width utilities.
  • No matter which approach you use, the SVG icons you use may need some tweaking so that the size and colors can be customised using Tailwind. For example, you may need to remove height, width, fill, and stroke attributes, either manually or programatically.

Conclusion

I don't think any one approach is better than the others, as they all have different trade-offs and might work better in different scenarios. For GiftyDuck I used the first approach, however at the time of writing I'm leaning towards the second approach.

Also check out Caleb Porzio's article on the same topic with some extra tips for handling widths and heights on SVG files.