SVG icons without the markup clutter

SVG remains the best choice for website icons, offering crisp rendering at any pixel density, CSS styling on hover, and even animation support. But getting SVGs onto a page typically means either littering your HTML with inline markup or managing external files and their HTTP requests.

One alternative: compile your icons directly into your CSS. A Sass function can take raw SVG source code, encode it into a data URI, and assign it to a CSS custom property on the root element. The result is a reusable icon library that lives entirely in your stylesheet.

Here’s what that looks like in practice, pulled directly from a production site:

.c-filters__summary h2:after {
  content: var(--svg-down-arrow);
  position: relative;
  top: 2px;
  margin-left: auto;
  animation: closeSummary .25s ease-out;
}

Why this approach?

Storing icons as encoded data URIs in CSS custom properties brings several practical benefits:

  • No separate HTTP requests for icon files
  • All icons live in a single, centralized list
  • Updating an icon means touching one source file, not multiple templates
  • Icons are cached alongside your CSS
  • No extra markup added to your HTML
  • You can still tweak colors or effects with CSS filters

There are trade-offs, though. You can’t target or animate individual parts of an SVG via CSS, and your compiled stylesheet grows with every icon you add. For that reason, this technique suits simple icons better than complex logos or illustrations — an encoded SVG will always be heavier than its original file, so elaborate graphics are still better served by external files loaded via <img> or url().

The mechanics: encoding SVG as data URI

Encoding SVGs as data URIs isn’t a new idea — it’s been documented for over a decade. In CSS, you can use an encoded SVG in two ways:

  • As an external image, via properties like background-image, border-image, or list-style-image
  • As the content of a pseudo-element such as ::before or ::after

The manual approach has clear downsides: every new icon requires hand-converting the SVG into a long, unreadable URI string. Sass eliminates that friction by handling the encoding automatically at compile time.

Building the Sass function

This technique is adapted from an existing implementation by Threespot Media, available in their Frontline Sass repository. The process breaks down into four steps.

1. Define your icon list

Start with a Sass variable containing the raw source code for each icon:

/**
* Add all the icons of your project in this Sass list
*/
$svg-icons: (
  burger: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24.8 18.92" width="24.8" height="18.92"><path d="M23.8,9.46H1m22.8,8.46H1M23.8,1H1" fill="none" stroke="#000" stroke-linecap="round" stroke-width="2"/></svg>'
);

2. Specify characters to escape

Data URIs require certain characters in SVG code to be escaped — typically #, <, >, and double quotes — to prevent browser parsing errors:

/**
* Characters to escape from SVGs
* This list allows you to have inline CSS in your SVG code as well
*/
$fs-escape-chars: (
  ' ': '%20',
  '\'': '%22',
  '"': '%27',
  '#': '%23',
  '/': '%2F',
  ':': '%3A',
  '(': '%28',
  ')': '%29',
  '%': '%25',
  '<': '%3C',
  '>': '%3E',
  '\\': '%5C',
  '^': '%5E',
  '{': '%7B',
  '|': '%7C',
  '}': '%7D',
);

3. Write the encoding function

The core function iterates through each character of the SVG source and replaces any characters flagged in the escape list with their percent-encoded equivalents:

/**
* You can call this function by using `svg(nameOfTheSVG)`
*/
@function svg($name) {
  // Check if icon exists
  @if not map-has-key($svg-icons, $name) {
    @error 'icon “#{$name}” does not exists in $svg-icons map';
    @return false;
  }

  // Get icon data
  $icon-map: map-get($svg-icons, $name);

  $escaped-string: '';
  $unquote-icon: unquote($icon-map);
  // Loop through each character in string
  @for $i from 1 through str-length($unquote-icon) {
    $char: str-slice($unquote-icon, $i, $i);

    // Check if character is in symbol map
    $char-lookup: map-get($fs-escape-chars, $char);

    // If it is, use escaped version
    @if $char-lookup != null {
        $char: $char-lookup;
    }

    // Append character to escaped string
    $escaped-string: $escaped-string + $char;
  }

  // Return inline SVG data
  @return url('data:image/svg+xml, #{$escaped-string} ');
}		

4. Use the function

Once defined, the function can be called anywhere CSS accepts an image value:

button {
  &::after {
    /* Import inline SVG */
    content: svg(burger);
  }
}

When compiled, Sass outputs the fully encoded URI:

button::after {
  content: url("data:image/svg+xml, %3Csvg%20xmlns=%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox=%270%200%2024.8%2018.92%27%20width=%2724.8%27%20height=%2718.92%27%3E%3Cpath%20d=%27M23.8,9.46H1m22.8,8.46H1M23.8,1H1%27%20fill=%27none%27%20stroke=%27%23000%27%20stroke-linecap=%27round%27%20stroke-width=%272%27%2F%3E%3C%2Fsvg%3E ");
}		

Storing icons in custom properties

The svg() function has a notable inefficiency: an icon used in multiple places gets duplicated in the compiled CSS, inflating file size. Custom properties solve that. Instead of calling the function repeatedly, loop through the icon list once and output each encoded icon as a variable on the root element:

/**
  * Convert all icons into custom properties
  * They will be available to any HTML tag since they are attached to the :root
  */

:root {
  @each $name, $code in $svg-icons {
    --svg-#{$name}: #{svg($name)};
  }
}

Icons are then referenced by variable name — using a consistent --svg prefix — rather than by re-encoding:

button::after {
  /* Import inline SVG */
  content: var(--svg-burger);
}

This keeps the compiled CSS lean. The encoded URI appears exactly once, stored in a single custom property that any rule can reference.

Optimizing your SVGs first

This workflow performs no optimization on its own. Any unnecessary code in your SVG source — comments, extra attributes, whitespace — gets encoded and adds to your CSS file’s weight. Running icons through an optimizer before adding them to your list is worthwhile.

Tools like Jake Archibald’s SVGOMG let you drag in a file and copy out a cleaned version. A list of other SVG optimization resources is also available.

Dealing with hover states and colors

Because the SVG is encoded as a URI, CSS can’t reach into its internal elements to change a fill color on hover. Two workarounds exist, each with trade-offs.

Using CSS filters for color shifts

For simple color changes — for instance, turning a black icon white on hover — the invert() filter works well. The hue-rotate() filter offers another way to shift a single-color icon’s appearance.

Using the mask-image property

A more flexible option is to use the icon as a CSS mask. Set the pseudo-element's background to the desired color, then size and position it, and mask it with the icon reference. The icon’s shape cuts out from the rectangle, leaving a colored icon:

  • mask-image: var(--svg-burger): References the stored icon variable
  • mask-repeat: no-repeat: Prevents the mask from tiling
  • mask-size: contain: Makes the icon fit within the element
  • mask-position: center: Centers the icon in its box

Keep in mind that as of September 2022, all CSS mask properties still require the -webkit- prefix for broad browser support. Changing the background-color of the pseudo-element lets you alter the icon’s color — even on hover — without losing the benefits of the encoded asset.