Icons are everywhere.
Almost every app uses them. And yet very little thought goes into how to optimse their performance.
The Epic Stack provides an opinionated way to manage icons in Remix. Let’s take a look at how all the pieces fit together to provide an optimised UX with an automated DX.
The Best Way To Manage Icons In React
In his article, The “best” way to manage icons in React.js, Ben Adam summarises the different approaches to manage icons in React. It’s well worth a read but the conclusion is to use SVG sprites. They give you the benefits of inline SVGs, like being able to style with CSS. But they prevent some of the JavaScript bloat that inline SVGs can bring by keeping the path data in an external asset.
function Icon() {
return (
<svg viewBox="0 0 24 24" width={16} height={16}>
<use href="sprite.svg#icon" />
</svg>
)
}
The only downside, in my opinion, is that there is an extra step involved with creating the sprite which isn’t needed with a React icon library. However, as we shall see, we will be able to automate most of the steps so the extra work will be worth it.
The Plan
Let’s take a look at how we plan to automate as much as possible. Here’s the plan:
- Create a folder of raw SVG files for each icon we want to use in the app. This can be done manually or via the command line with Sly.
- When we run
npm run build
(ornpm run dev
), run a script to convert these raw files into a single SVG sprite. - Use a single
Icon
component which takes the ID of the sprites and displays the correct sprite.
Creating The Raw SVG Files
First, we’ll create a directory with the SVG files for each icon in our app. This can be done manually but we’re going to do it with Sly.
Sly is a CLI tool that lets us choose an icon library and icons. After we run a command with Sly, the icon files will appear in our codebase as raw files (not a dependency).
Start by installing Sly as a dev dependency.
npm install --save-dev @sly-cli/sly
Next, choose somewhere to put the icon files. They should be outside your app
directory because our code won’t be interacting with these files directly. In the Epic Stack, there is an “Other” directory for anything that doesn’t have a place. It’s used to keep the root of our project as clean as possible.
Create an empty directory at other/svg-icons
. This will be the directory that holds our SVG files.
Now, we need to create a config file for Sly.
{
"$schema": "https://sly-cli.fly.dev/registry/config.json",
"libraries": [
{
"name": "@radix-ui/icons",
"directory": "./other/svg-icons",
"postinstall": ["npm", "run", "build:icons"],
"transformers": ["transform-icon.ts"]
}
]
}
In our case:
- We’re using Radix Icons as our icon library.
- The SVG files should be inserted in the
./other/svg-icons
directory - Once we’ve added more icons, we want to build them into our sprite. We do this by defining
npm run build:icons
as our postinstall script (we’ll be adding this command later). - Transformers let us customise the SVG files. In this case, we’ll be running
transform-icons.ts
. We’ll be creating that script later. And by later, I mean now.
Our transformer will prepend the SVG files with a few comments for additional meta information. In this example we’ll prepend the name of the library, a link to the license, and the URL of the source.
import { type Meta } from '@sly-cli/sly'
/**
* @type {import('@sly-cli/sly/dist').Transformer}
*/
export default function transformIcon(input: string, meta: Meta) {
input = prependLicenseInfo(input, meta)
return input
}
function prependLicenseInfo(input: string, meta: Meta): string {
return [
`<!-- Downloaded from ${meta.name} -->`,
`<!-- License ${meta.license} -->`,
`<!-- ${meta.source} -->`,
input,
].join('\n')
}
As an example, if we were to get the avatar SVG from @radix-ui/icons, our SVG would contain the following comments at the top:
<!-- Downloaded from @radix-ui/icons -->
<!-- License https://github.com/radix-ui/icons/blob/master/LICENSE -->
<!-- https://github.com/radix-ui/icons/blob/master/packages/radix-icons/icons/avatar.svg -->
Now we can run sly commands to add raw SVGs to our codebase from the command line. For example:
npx sly add @radix-ui/icons trash pencil-1 avatar
The only problem now is our missing postinstall command, so let’s move on to building the sprite from the raw SVGs.
Building the SVG Sprite
Now that we have our raw SVG files, we want to package them up into a single SVG sprite that we can use in our Icon component.
In our sly config file, we defined the postinstall as ["npm", "run", "build:icons"]
so let’s jump over to our package.json
and add that command. We’ll also do some organising of the other build commands.
Let’s start by installing npm-run-all
, which will let us break down our build
script into multiple scripts.
npm install npm-run-all --save-dev
This gives us access to the run-s
command, which can run npm-scripts sequentially. In this way, npm run build
becomes our entry point and specifies which other npm-scripts to run.
{
"scripts": {
"build": "run-s build:*",
"build:icons": "tsx ./other/build-icons.ts",
"build:remix": "remix build",
"predev": "npm run build:icons --silent",
}
}
run-s build:*
tells the CLI to run every npm-script that begins withbuild:
build:icons
is the command which will turn our individual SVG icons into a single sprite. It’s defined as running a script called./other/build-icons.ts
which we will create next.build:remix
is the originalbuild
script when we created the app.
Finally, we create a predev
script. This will run the build:icons
script whenever the npm run dev
command is executed. This makes sure we are using the latest SVG sprite whenever the app server is run in dev mode.
Before we write the script to build the icons, we first need to install the dependencies it’ll use.
npm install execa glob
npm install --save-dev tsx fs-extra @types/fs-extra @types/glob node-html-parser
tsx
will be used to compile and run the TypeScript file. The rest will by used by the script itself.
Now let’s create a file at other/build-icons.ts
.
import * as path from 'node:path'
import { $ } from 'execa'
import fsExtra from 'fs-extra'
import { glob } from 'glob'
import { parse } from 'node-html-parser'
const cwd = process.cwd()
const inputDir = path.join(cwd, 'other', 'svg-icons')
const inputDirRelative = path.relative(cwd, inputDir)
const outputDir = path.join(cwd, 'app', 'components', 'ui', 'icons')
await fsExtra.ensureDir(outputDir)
const files = glob
.sync('**/*.svg', {
cwd: inputDir,
})
.sort((a, b) => a.localeCompare(b))
const shouldVerboseLog = process.argv.includes('--log=verbose')
const logVerbose = shouldVerboseLog ? console.log : () => {}
if (files.length === 0) {
console.log(`No SVG files found in ${inputDirRelative}`)
} else {
await generateIconFiles()
}
async function generateIconFiles() {
const spriteFilepath = path.join(outputDir, 'sprite.svg')
const typeOutputFilepath = path.join(outputDir, 'name.d.ts')
const currentSprite = await fsExtra
.readFile(spriteFilepath, 'utf8')
.catch(() => '')
const currentTypes = await fsExtra
.readFile(typeOutputFilepath, 'utf8')
.catch(() => '')
const iconNames = files.map(file => iconName(file))
const spriteUpToDate = iconNames.every(name =>
currentSprite.includes(`id=${name}`),
)
const typesUpToDate = iconNames.every(name =>
currentTypes.includes(`"${name}"`),
)
if (spriteUpToDate && typesUpToDate) {
logVerbose(`Icons are up to date`)
return
}
logVerbose(`Generating sprite for ${inputDirRelative}`)
const spriteChanged = await generateSvgSprite({
files,
inputDir,
outputPath: spriteFilepath,
})
for (const file of files) {
logVerbose('✅', file)
}
logVerbose(`Saved to ${path.relative(cwd, spriteFilepath)}`)
const stringifiedIconNames = iconNames.map(name => JSON.stringify(name))
const typeOutputContent = `// This file is generated by npm run build:icons
export type IconName =
\t| ${stringifiedIconNames.join('\n\t| ')};
`
const typesChanged = await writeIfChanged(
typeOutputFilepath,
typeOutputContent,
)
logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`)
const readmeChanged = await writeIfChanged(
path.join(outputDir, 'README.md'),
`# Icons
This directory contains SVG icons that are used by the app.
Everything in this directory is generated by \`npm run build:icons\`.
`,
)
if (spriteChanged || typesChanged || readmeChanged) {
console.log(`Generated ${files.length} icons`)
}
}
function iconName(file: string) {
return file.replace(/\.svg$/, '')
}
/**
* Creates a single SVG file that contains all the icons
*/
async function generateSvgSprite({
files,
inputDir,
outputPath,
}: {
files: string[]
inputDir: string
outputPath: string
}) {
// Each SVG becomes a symbol and we wrap them all in a single SVG
const symbols = await Promise.all(
files.map(async file => {
const input = await fsExtra.readFile(path.join(inputDir, file), 'utf8')
const root = parse(input)
const svg = root.querySelector('svg')
if (!svg) throw new Error('No SVG element found')
svg.tagName = 'symbol'
svg.setAttribute('id', iconName(file))
svg.removeAttribute('xmlns')
svg.removeAttribute('xmlns:xlink')
svg.removeAttribute('version')
svg.removeAttribute('width')
svg.removeAttribute('height')
return svg.toString().trim()
}),
)
const output = [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<!-- This file is generated by npm run build:icons -->`,
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">`,
`<defs>`, // for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs
...symbols,
`</defs>`,
`</svg>`,
'', // trailing newline
].join('\n')
return writeIfChanged(outputPath, output)
}
async function writeIfChanged(filepath: string, newContent: string) {
const currentContent = await fsExtra
.readFile(filepath, 'utf8')
.catch(() => '')
if (currentContent === newContent) return false
await fsExtra.writeFile(filepath, newContent, 'utf8')
await $`prettier --write ${filepath} --ignore-unknown`
return true
}
This script will take our directory of raw SVGs as an input and output a directory at app/components/ui/icons
with three files inside:
[README.md](http://README.md)
which explains the purpose of the directorysprite.svg
containing the outputted SVG sprite.name.d.ts
containing theIconName
type. This is a union of all the SVG filenames and will be used as the type for theIcon
component prop to define which SVG to render.
The script also removes a couple of attributes from the SVGs, including the width
and height
props. This is to ensure they scale properly.
The Icon Component
Now that we have generated our SVG sprite, we’re ready to create our Icon component.
import { type SVGProps } from 'react'
import { cn } from '#app/utils/misc.tsx'
import { type IconName } from '@/icon-name'
import href from './icons/sprite.svg'
export { href }
export { IconName }
const sizeClassName = {
font: 'w-[1em] h-[1em]',
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
xl: 'w-7 h-7',
} as const
type Size = keyof typeof sizeClassName
const childrenSizeClassName = {
font: 'gap-1.5',
xs: 'gap-1.5',
sm: 'gap-1.5',
md: 'gap-2',
lg: 'gap-2',
xl: 'gap-3',
} satisfies Record<Size, string>
export function Icon({
name,
size = 'font',
className,
children,
...props
}: SVGProps<SVGSVGElement> & {
name: IconName
size?: Size
}) {
if (children) {
return (
<span
className={`inline-flex items-center ${childrenSizeClassName[size]}`}
>
<Icon name={name} size={size} className={className} {...props} />
{children}
</span>
)
}
return (
<svg
{...props}
className={cn(sizeClassName[size], 'inline self-center', className)}
>
<use href={`${href}#${name}`} />
</svg>
)
}
There’s a lot going on in the file, so let’s break it down and build it up slowly.
import { type SVGProps } from 'react'
import { type IconName } from '@/icon-name'
import href from './icons/sprite.svg'
export function Icon({
name,
...props
}: SVGProps<SVGSVGElement> & {
name: IconName
}) {
return (
<svg
{...props}
>
<use href={`${href}#${name}`} />
</svg>
)
}
This is our core component. It renders an SVG and uses the use
element to refer to the part of the SVG sprite we want to render.
- The
href
is imported from the rawsprite.svg
file - The
name
is taken as a prop. The type is the type generated by thebuild-icons.ts
script.
You may notice something strange about the way we’re importing the IconName
. The import path is @/icon-name
rather than the one we would expect in the Epic Stack (#app/components/ui/icons/name.d.ts
).
The reason we’re using a different import path is to address the question, “What should the type of IconName
be if we haven’t yet generated the name.d.ts
file?”.
For example, we may have just downloaded the project. Or maybe we haven’t added any raw SVGs to our svg-icons
directory. How can we import from #app/components/ui/icons/name.d.ts
if it doesn’t exist?
The Epic Stack solution is to create a fallback file, types/icon-name.d.ts
.
export type IconName = string;
This file just defines IconName
as a string and provides a value to satisfy the TypeScript compiler until we’ve run npm run build:icons
to generate the strict IconName
type.
We can tell TypeScript to use types/icon-name.d.ts
as the fallback by updating tsconfig.json
{
"compilterOptions": {
"paths": {
"#*": ["./*"],
"@/icon-name": [
"./app/components/ui/icons/name.d.ts",
"./types/icon-name.d.ts"
]
},
}
}
Now when we use the @/icon-name
import, TypeScript will try to use app/components/ui/icons/name.d.ts
. If it doesn’t exist, it will fall back to types/icon-name.d.ts
.
Let’s next update the component file to re-export the href
and IconName
type. In this way, the Icon.tsx
file becomes the source of all icon-related imports.
import { type SVGProps } from 'react'
import { type IconName } from '@/icon-name'
import href from './icons/sprite.svg'
export { href }
export { IconName }
export function Icon({
name,
...props
}: SVGProps<SVGSVGElement> & {
name: IconName
size?: Size
}) {
return (
<svg
{...props}
>
<use href={`${href}#${name}`} />
</svg>
)
}
Now let’s expose a size
prop. This will give our icons some consistency by providing standard sizes of icons. However, we’ll also allow the user to pass in custom classNames if they want to customise the icon in a way that isn’t supported by the size
prop.
import { type SVGProps } from 'react'
import { type IconName } from '@/icon-name'
import href from './icons/sprite.svg'
export { href }
export { IconName }
const sizeClassName = {
font: 'w-[1em] h-[1em]',
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
xl: 'w-7 h-7',
} as const
type Size = keyof typeof sizeClassName
export function Icon({
name,
size = 'font',
className,
...props
}: SVGProps<SVGSVGElement> & {
name: IconName
}) {
return (
<svg
{...props}
className={cn(sizeClassName[size], className)}
>
<use href={`${href}#${name}`} />
</svg>
)
}
We take a size
prop and apply the relevant styling className (in this case using tailwind). The default size is the current font size (1em).
The cn
utility comes from shadcn-ui. It handles combining classNames, filtering out conflicting tailwind classes, etc. We pass the className
prop after the sizeClassName
value to make sure any custom className
s are applied instead of the default values.
Finally, it’s fairly common for an icon to be rendered to the left of a bit of text. Therefore, we handle that with our Icon component using regression.
If anything is passed as a child to the Icon, we’ll render a span with the Icon component next to the children. The Icon component in this case won’t have any children, so it will just render the base case (i.e. the SVG).
import { type SVGProps } from 'react'
import { cn } from '#app/utils/misc.tsx'
import { type IconName } from '@/icon-name'
import href from './icons/sprite.svg'
export { href }
export { IconName }
const sizeClassName = {
font: 'w-[1em] h-[1em]',
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
xl: 'w-7 h-7',
} as const
type Size = keyof typeof sizeClassName
const childrenSizeClassName = {
font: 'gap-1.5',
xs: 'gap-1.5',
sm: 'gap-1.5',
md: 'gap-2',
lg: 'gap-2',
xl: 'gap-3',
} satisfies Record<Size, string>
export function Icon({
name,
size = 'font',
className,
children,
...props
}: SVGProps<SVGSVGElement> & {
name: IconName
size?: Size
}) {
if (children) {
return (
<span
className={`inline-flex items-center ${childrenSizeClassName[size]}`}
>
<Icon name={name} size={size} className={className} {...props} />
{children}
</span>
)
}
return (
<svg
{...props}
className={cn(sizeClassName[size], 'inline self-center', className)}
>
<use href={`${href}#${name}`} />
</svg>
)
}
Finishing Touches
As a final optimisation, we can prefetch the SVG sprite to prevent it from render blocking.
import { type LinksFunction } from '@remix-run/node'
import { href as iconsHref } from './components/ui/icon.tsx'
export const links: LinksFunction = () => {
return [
{ rel: 'preload', href: iconsHref, as: 'image' },
// Other root links
]
}
And we’re done! We now have beautifully optimised icons that are type-safe and almost fully automated. 🎉