Using SVGs as Astro components and inline CSS

I really like being able to keep files in their respective formats as it often means we can reuse them from multiple places, but interoperability can also be a great hassle, causing us to shoehorn data from one format into another.

When I used to use Vue a lot I'd always add the Vue SVG loader to my projects, as it would allow me to import SVG files as Vue components. Later on I discovered the postcss-inline-svg plugin which would allow me to inline SVG files in CSS. Much like importing them as Vue components, one of the key benefits of this plugin is it would allow me to style the SVG where I used it, for example changing the fill colour or line width for an individual import. Nowadays whenever I need to use SVG with a new tool I'm looking for equivalent functionality.

In this tutorial we're going to make an Astro component that will accept the name of an SVG and inline it in our HTML. It will also allow us to add classes, aria attributes and the works. We'll also set up our project to be able to inline the same SVG files into our CSS. I've create a repo for this tutorial so if you find yourself stuck at all please take a look there.

It's worth noting there is already an astro-icon package for importing SVG files as Astro components. It gives you access to a bunch of different icon libraries out of the box and lets you do things like host your own icon packs remotely. If you're just looking for an off-the-shelf package to add an Astro component I'd recommend you take a look there. I wanted to figure out how I could get something like this working myself, hence I built my own thing and wrote this article on it.

1. Defining our requirements

Before we begin writing any code let's outline what we want to achieve:

  1. To begin with we want to keep all the SVGs we use in our project as .svg files in one directory.
  2. We want to be able to use SVGs in our Astro templates as if they were any other component.
  3. We want to be able to inline the same SVG files into our CSS too.
  4. We'd also like to be able to customise the imported SVG wherever we use them, for example in an Astro template we might want to add a class to the SVG, and in CSS we might want to change the fill or stroke colour.

Since you're following a tutorial here, I'll assume you're happy with the requirements I've outlined above. If there's anything extra you want from this, hopefully by following along you'll be able to see where you can adapt it to fit your needs.

2. Setting up our icons directory

First of all create a src/svg/ directory within your project. If you don't already have any icons to play around with, for now I recommend you copy a few from the wonderful heroicons project just to test with.

You can use a different directory if you wish, it's not critical. Just make sure to change any references to src/svg/ to your chosen location whenever it crops up.

3. Creating the Astro component

Create an icon.astro file in src/components/. To begin with it's just going to accept the name of an svg file as a prop. For example, if we wanted to use cart.svg we'd use it like this:

<Icon icon="cart" />

As such lets add an icon prop to it. Your icon.astro should look like this:

---
export interface Props {
  icon: string;
}

const { icon } = Astro.props as Props;
const { default: innerHTML } = await import(`/src/svg/${icon}.svg?raw`);
---

<Fragment set:html={innerHTML} />

We're using TypeScript to let our code editor know which props the component will accept here.

The Fragment element is a special Astro element that doesn't get rendered itself, but its children do. We can't give it any children directly because we don't know ahead of time which SVG we need, so we're using the set:html directive instead to let Astro render whatever we pass as the innerHTML variable as HTML inside our SVG.

We're setting the innerHTML variable with a dynamic import. By using the ?raw querystring, Vite will import the contents of the SVG file as a string rather than trying to convert it to anything else.

4. Supporting HTML attributes

Creating a dynamic SVG component was easy enough, right? Well not so fast. We've ticked off requirement 2, you can now import and use your astro component elsewhere like so:

---
import Icon from '../components/icon.astro';
---

<Icon icon="cart" />

We can't add any attributes to the component though. The first thing you might try is this:

---
export interface Props {
  icon: string;
}

const { icon, ...attributes } = Astro.props as Props;
const { default: innerHTML } = await import(`/src/svg/${icon}.svg?raw`);
---

<Fragment {...attributes} set:html={innerHTML}>

Here we're collecting any attributes/props except for icon and applying them to the parent component. Since the parent component is a Fragment though, it won't be rendered and as such cannot accept any attributes. We'd need to apply our attributes to the innerHTML string somehow.

You might also try this at first:

function addAttributesToInnerHTMLString(innerHTML: string, attributes: Record<string, string>) {
  const attributesString = Object.entries(attributes)
    .map(([key, value]) => `${key}="${value}"`)
    .join(' ');

  return innerHTML.replace(/^<svg/, `<svg ${attributesString}`);
}

const { default: svg } = await import(`/src/svg/${icon}.svg?raw`);
const innerHTML = addAttributesToInnerHTMLString(svg, attributes);

The main trouble here is you might want to override an attribute that already exists on the SVG. For example the SVG might have color="currentColor" set. If you pass your own attribute it will end up looking something like this:

<svg color="tomato" color="currentColor">
  <!-- ... -->
</svg>

There's probably more fancy stuff you could do too, but I decided the safest way to achieve it would be to let Astro apply the attributes to the SVG for me, and ended up with this:

<svg {...attributes} set:html={innerHTML}></svg>

The trouble with this is currently our innerHTML begins with an SVG element, so we need to parse it and extract all the content from it to render it inside our new SVG.

Rather than write my own parser I opted to install a third party one called node-html-parser.

Using yarn run:

yarn add --dev node-html-parser

Or using npm:

npm install --save-dev node-html-parser

Then add the following to your component:

import { parse } from 'node-html-parser';

function getSVG(name: string) {
  const filepath = `/src/svg/${name}.svg`;
  const files = import.meta.globEager<string>('/src/svg/**/*.svg', {
    as: 'raw',
  });

  if (!(filepath in files)) {
    throw new Error(`${filepath} not found`);
  }

  const root = parse(files[filepath]);

  const svg = root.querySelector('svg');
  const { attributes, innerHTML } = svg;

  return {
    attributes,
    innerHTML,
  };
}

The getSVG function works by importing all SVGs in our /src/svg directory and selecting the one we've chosen.

In order to import all of them it uses Vites globEager function to get all files that match the /src/svg/**/*.svg glob, and we're passing the { as: 'raw' } param, which is equivalent to adding the ?raw querystring in an import call, to ensure we just get the content as a string.

globEager returns all our imports in an object a bit like this, where each key is the path required to import it directly, and each value is the content of the file:

{
  "/src/svg/arrow-down.svg": "<svg>...</svg>",
  "/src/svg/arrow-left.svg": "<svg>...</svg>",
  "/src/svg/cart.svg": "<svg>...</svg>",
  "/src/svg/user.svg": "<svg>...</svg>",
}

As such we can pass the SVG we've requested with files[filepath] to the parser we installed earlier. The API it provides is very DOM-like, so we just need to run root.querySelector('svg') in order to get the first SVG element it finds.

Much like if we were accessing it in the browser innerHTML will give us the content of the SVG now, but by doing so we'll lose all the attributes attached to it. As such we need to return svg.innerHTML and svg.attributes. We could just return svg directly from the function, but this would also provide a lot of extra data we don't need. It's up to you which you'd prefer here, I prefer to return as little as necessary in this instance.

Note: If you want to do anything else with your SVG like optimize it with SVGO, you probably want to do that here too.

Finally we can change our icon.astro component to this:

---
import { parse } from 'node-html-parser';

export interface Props {
  icon: string;
}

function getSVG(name: string) {
  const filepath = `/src/svg/${name}.svg`;
  const files = import.meta.globEager<string>('/src/svg/**/*.svg', {
    as: 'raw',
  });

  if (!(filepath in files)) {
    throw new Error(`${filepath} not found`);
  }

  const root = parse(files[filepath]);

  const svg = root.querySelector('svg');
  const { attributes, innerHTML } = svg;

  return {
    attributes,
    innerHTML,
  };
}

const { icon, ...attributes } = Astro.props as Props;
const { attributes: baseAttributes, innerHTML } = getSVG(icon);

const svgAttributes = { ...baseAttributes, ...attributes };
---

<svg
  {...svgAttributes}
  set:html={innerHTML}
></svg>

Now we're merging all the extra attributes we'd like along with any attributes already attached to the SVG, and we're applying them all via Astro. This means we have requirements 1, 2 and half of 4 on our list checked.

5. Inlining SVGs into CSS

The final step should be easy because we're not going to write this functionality ourselves. Install postcss-inline-svg.

yarn add --dev postcss-inline-svg

Or with npm:

npm install --save-dev postcss-inline-svg

Create a postcss.config.js file in your project root too:

module.exports = {
  plugins: {
    'postcss-inline-svg': {
      paths: ['src/svg'],
    },
  },
};

Now restart Astro if it was already running and add the following to your CSS just to test that it works:

body {
  background-image: svg-load('arrow-down.svg', stroke=tomato, width=20, height=20, transform=rotate(-45));
}

You should now see the SVG you just imported as a background image, with a tomato fill. If you don't, make sure the body element doesn't have a background of any sort being applied elsewhere.

Now we've got a system that fulfills all of our original requirements.

Hopefully this also gives you an appreciation of how we can hook into Astro component scripts to render content from outside our Astro components at build time.