24 January, 2024 | 14 min read

Optimal Icons in Remix

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:

  1. 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.
  2. When we run npm run build (or npm run dev), run a script to convert these raw files into a single SVG sprite.
  3. 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.

other/sly/sly.json
{
  "$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.

other/sly/transform-icon.ts
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.

package.json
{
  "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 with build:
  • 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 original build 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.

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 directory
  • sprite.svg containing the outputted SVG sprite.
  • name.d.ts containing the IconName type. This is a union of all the SVG filenames and will be used as the type for the Icon 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.

app/components/ui/icon.tsx
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.

app/components/ui/icon.tsx
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 raw sprite.svg file
  • The name is taken as a prop. The type is the type generated by the build-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.

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

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.

app/components/ui/icon.tsx
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.

app/components/ui/icon.tsx
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 classNames 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).

Delete

app/components/ui/icon.tsx
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.

app/root.tsx
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. 🎉


Follow me on Twitter or Bluesky to get updates on new posts.