Go back
Lazy load SVG icons with <use/> in React.js

Lazy load SVG icons with <use/> in React.js

Thursday 6 February 2025 Reading time: 7 minutes

Using icons in your website or app almost always brings up the question of optimization.

Icons used in this article are extracted from the Remix Icon library.

The inline way

Maybe you are using a popular icon library like react-icon, or @remixicon/react. In that case, each icon you import will likely be inline injected in your final HTML. Depending on the complexity of these icons or their number, this can lead to a significant increase in your bundle size.

The sprite way

Another approach that I find interesting is to use SVG icons with the <use> tag. This way, you can reference the same SVG file multiple times in your HTML without having to duplicate the SVG code.

Downloading, optimizing (with SVGOMG), and importing icons inside your project can be a lot of work if you need numerous icons for your application but starting from scratch you'll generally add icons one by one when needed so it doesn't feel like a big deal.

The basic setup for this usage it to create a single .svg sprite file that will reference several icons inside elements.

html
<svg
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
  style="display: none"
>
  <symbol id="arrow-right" viewBox="0 0 24 24">
    <path
      d="m16.172 11-5.364-5.364 1.414-1.414L20 12l-7.778 7.778-1.414-1.414L16.172 13H4v-2h12.172Z"
    />
  </symbol>
  <symbol id="arrow-left" viewBox="0 0 24 24">
    <path
      d="M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414L7.828 11Z"
    />
  </symbol>
</svg>

If your sprite.svg is located in /images/icons/sprite.svg, you can call your icons like this:

html
<svg>
  <use href="/images/icons/sprite.svg#arrow-right"></use>
</svg>

Doing this will allow you to reference the same SVG file multiple times without increasing your bundle size.

However, if you use only one or two icons in a specific page, you'll load the entire sprite file just for these icons. Depending on its size, it can be acceptable or not.

Splitting your sprite into individual files

To avoid loading the entire sprite file, you can split your sprite into individual files.

html
<!-- arrow-left.svg -->
<svg
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
  style="display: none"
>
  <symbol id="icon" viewBox="0 0 24 24">
    <path
      d="M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414L7.828 11Z"
    />
  </symbol>
</svg>

<!-- arrow-right.svg -->
<svg
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
  style="display: none"
>
  <symbol id="icon" viewBox="0 0 24 24">
    <path
      d="m16.172 11-5.364-5.364 1.414-1.414L20 12l-7.778 7.778-1.414-1.414L16.172 13H4v-2h12.172Z"
    />
  </symbol>
</svg>

These icons can be used like this:

html
<svg>
  <use href="/images/icons/arrow-right.svg#icon"></use>
</svg>

Browser cache is doing the rest: icons used in several places will be loaded instantly after their first use.

Usage in React.js

In order to reduce boilerplate, you can create a component that will handle the loading of your icons.

tsx
// @/components/icon.tsx

type Props = React.SVGProps<SVGSVGElement> & {
  code: string;
};

export default function Icon({ code, ...props }: Props) {
  return (
    <svg width={24} height={24} {...props}>
      <use href={`/images/icons/${code}.svg#icon`} />
    </svg>
  );
}

Usage:

tsx
<Icon code="arrow-right" />

Now with lazy loading

I find it interesting to lazy load these icons because it allows for a reduced number of request during the initial page load: only icons that are in view are requested over the network.

You'll need to adjust your icon.tsx component like this:

tsx
// @/components/icon.tsx

// This code implements lazy loading for SVG icons using the Intersection Observer API.

type Props = React.SVGProps<SVGSVGElement> & {
  code: string;
};

export default function Icon({ code, ...props }: Props) {
  // Creates a ref to track the SVG element
  const ref = React.useRef<SVGSVGElement>(null);
  // Uses useState to track if the icon is in viewport
  const [inView, setInView] = React.useState(false);

  React.useEffect(() => {
    // Checks if IntersectionObserver is supported by the browser
    const isCompatible = "IntersectionObserver" in window;
    if (isCompatible) {
      const svg = ref.current;
      if (svg) {
        // Creates an observer that triggers when icon enters viewport
        const observer = new IntersectionObserver(
          ([entry]) => setInView(entry.isIntersecting),
          // Adds a root margin to trigger the observer a bit earlier: 24px before svg enters the viewport
          { rootMargin: "24px" }
        );
        // Sets up observation of the SVG element on mount
        observer.observe(svg);
        return () => {
          // Cleans up by unobserving on unmount
          observer.unobserve(svg);
        };
      }
    } else {
      // Falls back to always showing the icon
      setInView(true);
    }
  }, []);

  // Only sets the SVG reference when icon is in view
  // Prevents unnecessary loading of SVG icons outside viewport
  const href = inView ? `/images/icons/${code}.svg#icon` : undefined;

  return (
    <svg ref={ref} width={24} height={24} {...props}>
      <use href={href} />
    </svg>
  );
}

Here the result in video:

Below the code I use on my website without comments and with the addition of a IconCode type more specific allowing for auto completion for the code property when using the <Icon /> components.

tsx
"use client";

import * as React from "react";

export type IconCode = "arrow-left" | "arrow-right";

type Props = React.SVGProps<SVGSVGElement> & {
  code: IconCode;
};

export default function Icon({ code, ...props }: Props) {
  const ref = React.useRef<SVGSVGElement>(null);
  const [inView, setInView] = React.useState(false);

  React.useEffect(() => {
    const isCompatible = "IntersectionObserver" in window;
    if (isCompatible) {
      const svg = ref.current;
      if (svg) {
        const observer = new IntersectionObserver(
          ([entry]) => setInView(entry.isIntersecting),
          { rootMargin: "24px" }
        );
        observer.observe(svg);
        return () => {
          observer.unobserve(svg);
        };
      }
    } else {
      setInView(true);
    }
  }, []);

  const href = inView ? `/images/icons/${code}.svg#icon` : undefined;

  return (
    <svg ref={ref} width={24} height={24} {...props}>
      <use href={href} />
    </svg>
  );
}

Of course, this is only an approach to this specific problematic, this code can be adjusted or completely rewritten if necessary.