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
<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>Icon component
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
- Go to lucide.dev
- Select an icon you want
- Click "Copy SVG"
- Paste it into your
sprite.svg - Replace
<svg>with a<symbol id="zoom-out"> - Update
IconNamestype inIcon.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.