Low-tech icon setup in react

Super simple but manual icon system.

There are a few ways to do icons in a React project. What irks me in some of the keys-in-hand icon systems is the package install size, several hundreds of megabytes. You of course don't load all of that in the frontend. For a more complete look at the tradeoffs of React SVG icons, read the guide to handling icons as React components.

I've tried few things and somehow settled into a very low tech and manual system which has served me well.

Icon sprite

sprite.svg
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0">
  <symbol id="arrow-left-bar">
    <path d="M21 12h-18" />
    <path d="M6 9l-3 3l3 3" />
    <path d="M21 9v6" />
  </symbol>
  <symbol id="arrow-left-to-line">
    <path d="M3 19V5" />
    <path d="m13 6-6 6 6 6" />
    <path d="M7 12h14" />
  </symbol>
  <symbol id="arrow-right">
    <path d="M5 12l14 0" />
    <path d="M13 18l6 -6" />
    <path d="M13 6l6 6" />
  </symbol>
  <!-- more icons... -->
</svg>
Place the icon in yout public dir

Icon component

Icon.tsx
import type { ComponentProps } from 'react'
 
// Update this when adding new icons
export type IconNames = 'arrow-left-bar' | 'arrow-left-to-line' | 'arrow-right'
 
// React doesn’t include types for data attributes
type DataAttributes = Record<
  `data-${string}`,
  string | boolean | number | undefined
>
 
export interface IconProps
  extends
    Partial<
      Omit<ComponentProps<'svg'>, 'width' | 'height' | 'name' | 'viewBox'>
    >,
    DataAttributes {
  isFilled?: boolean
  label?: string
  name: IconNames
  size?: number
}
 
const filledProps = { fill: 'currentColor', strokeWidth: 0 } as const
const outLineProps = {
  fill: 'none',
  stroke: 'currentColor',
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
} as const
 
// The svg sprite is lives in public dir
export function Icon({
  name,
  label,
  size = 16,
  strokeWidth = 2,
  isFilled,
  ...props
}: IconProps) {
  return (
    <svg
      // You can choose to fill an icon, some icons work filled, other don't
      {...(isFilled ? filledProps : { ...outLineProps, strokeWidth })}
      {...props}
      aria-hidden={!label}
      height={size}
      viewBox='0 0 24 24'
      width={size}
    >
      {!!label && <title>{label}</title>}
      <use href={`/sprite.svg#${name}`} />
      {/* Animate the load spinner */}
      {name === 'loader' && (
        <animateTransform
          attributeName='transform'
          dur='1s'
          repeatCount='indefinite'
          type='rotate'
          values='0;360'
        />
      )}
    </svg>
  )
}

Preload it

<link as="image" href="/sprite-17.svg" rel="preload" type="image/svg+xml" />

In react-router you can use the meta utility:

export const meta = () => {
  return [
    {
      as: 'image',
      href: `/sprite.svg`,
      rel: 'preload',
      tagName: 'link',
      type: 'image/svg+xml',
    },
  ]
}

Adding icons

  1. Go to lucide.dev
  2. Select an icon you want
  3. Click "Copy SVG"
  4. Paste it into your sprite.svg
  5. Replace <svg> with a <symbol id="zoom-out">
  6. Update IconNames type in Icon.tsx

The SVG:

<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-icon lucide-ellipsis">
  <circle cx="12" cy="12" r="1"/>
  <circle cx="19" cy="12" r="1"/>
  <circle cx="5" cy="12" r="1"/>
</svg>

Replaced with <symbol>:

<symbol id="ellipsis">
  <circle cx="12" cy="12" r="1"/>
  <circle cx="19" cy="12" r="1"/>
  <circle cx="5" cy="12" r="1"/>
</symbol>

Busting the caches

You should have far expiring cache headers for the sprite.svg. The best and the simplest way to bust the caches in a situation like this, is to rename the sprite to, say, sprite-2.svg.

Then do a search and replace on your codebase for sprite.svg > sprite-2.svg.

Who's it for?

Not for enterprise, more for solo devs or small teams. There's no hassle related to icon build step or extra deps, you trade simplicity to manual setup.