SVG sprites are a powerful tool for optimizing icon usage in frontend development. By consolidating multiple icons into a single file, they significantly reduce the number of HTTP requests, leading to faster page loads. Unlike importing SVGs as JSX components, which can bloat JavaScript bundles and impact runtime performance, sprites offer a more efficient solution. This approach is particularly beneficial for large-scale applications where icon usage is extensive, potentially saving hundreds of kilobytes in bundle size. Implementing SVG sprites can greatly enhance overall application performance and user experience.
Using SVG Sprites to render icons is a highly effective method for performance optimization. This technique consolidates all icons into a single SVG file, defining each icon within a <symbol> element. Here's a simple explanation of how it works:
- • First, create a file named sprite.svg and add all icons as <symbol> elements within it.
- • Assign a unique id to each <symbol>.
- • In React, create an Icon component that references this sprite.
- • The Icon component uses the <use> element to call a specific icon from the sprite by its id.
- • This method keeps all icon data out of the JavaScript bundle while maintaining the advantages of inline SVGs, thus reducing bundle size.
This approach allows for efficient management and use of numerous icons while improving page load performance.
If you're ready, let's implement the approach described on James Blog:
Start by creating a dedicated folder in your Next.js project root to store your SVG icons. Name this folder /other/svg. This central location will house all your SVG files.
For better organization, especially when using multiple icon libraries, create subfolders within /other/svg. For instance, you might have /other/svg/lucide for Lucide icons, /other/svg/radix for Radix icons, and so on.
Adding Icons Manually
You can manually add icons by copying the SVG code into individual .svg files within the appropriate subfolder. However, this process can be time-consuming and error-prone for large icon sets.
Automating Icon Addition with Sly-cli
To streamline the process of adding icons, we'll use a tool called Sly-cli. This utility allows you to extract SVG files from popular React icon libraries without including unnecessary React-specific code.
Sly-cli adds the raw SVG code from dependencies without importing the entire library, keeping your project lightweight.
Using Sly-cli
Here's an example of how to use Sly-cli to add Radix UI icons:
npx @sly-cli/sly add @radix-ui/icons camera card-stack --yes --directory ./other/svg/radix
This command adds the 'camera' and 'card-stack' icons from the Radix UI icon set to the /other/svg/radix directory.
Interactive Mode
For a more user-friendly experience, you can use Sly-cli's interactive mode:
npx @sly-cli/sly add
This command launches an interactive interface in your terminal, allowing you to:
- • Browse available icon libraries
- • View and select specific icons
- • Choose the destination folder for your icons
An SVG sprite is a single SVG file that contains multiple icons, each wrapped in a <symbol> element. This approach allows for efficient use of icons across your application.
Creating the SpriteTo generate this sprite, we'll use a custom script. This script needs to be run each time you add new icons. If you're using Sly-cli, you can configure it to run this script automatically after adding new icons (we'll cover this in the sly.json configuration later).
Installing DependenciesFirst, we need to install some necessary dependencies. Run the following command in your project directory:
npm install fs-extra glob node-html-parser @types/fs-extra --save-dev
These packages will help us with file system operations, file matching, and HTML parsing.
Creating the Build ScriptNext, we'll create a script to build our sprite. For a Next.js project, create a file named build-icons.mts in the root directory of your project.
Note: The .mts file extension is used for TypeScript files in ECMAScript module format. This is necessary for the default Next.js app setup. If you're running your app in module mode, you might use a different extension.
This script will:
- • Scan the /other/svg directory for SVG files
- • Convert each SVG into a <symbol> element
- • Combine all symbols into a single SVG sprite file
- • Generate TypeScript type definitions for your icons
import * as path from "node:path"
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")
const inputDirRelative = path.relative(cwd, inputDir)
const typeDir = path.join(cwd, "types")
const outputDir = path.join(cwd, "public", "icons")
await fsExtra.ensureDir(outputDir)
await fsExtra.ensureDir(typeDir)
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(typeDir, "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(inputDir, "README.md"),
`# Icons
This directory contains SVG icons that are used by the app.
Everything in this directory is made into a sprite using \`npm run build:icons\`. This file will show in /public/icons/sprite.svg
`
)
if (spriteChanged || typesChanged || readmeChanged) {
console.log(`Generated ${files.length} icons`)
}
}
function iconName(file: string) {
return file.replace(/\.svg$/, "").replace(/\\/g, "/")
}
/**
* 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")
return true
}
Adding the Build Command to package.json
To make our icon compilation process easily accessible, we'll add a custom script to our package.json file.
Open your package.json file.
In the "scripts" section, add the following line:
{ "scripts": { // ... other scripts "build:icons": "npx tsx ./build-icons.mts" } }
This new script, build:icons, uses npx tsx to run our TypeScript file build-icons.mts.
Running the Build Script
With this script in place, you can now compile your icons into a sprite by running:
npm run build:icons
Results of Running the Script
When you run this command, two important files will be generated:
sprite.svg: This file will appear in the /public/icons directory. It contains all your icons as <symbol> elements within a single SVG file.
name.d.ts: This file will be created in the types directory. It's a TypeScript declaration file that defines the names of all your icons as a union type.
The name.d.ts file is crucial for TypeScript support. It allows your IDE to provide autocomplete suggestions for icon names and catches any typos or references to non-existent icons at compile time.
By integrating this build process into your workflow, you ensure that your SVG sprite is always up-to-date with your latest icons, and your TypeScript types accurately reflect the available icons in your project.
Using SVG Sprites in Next.js
After creating our SVG sprite, we can now use these icons efficiently in our Next.js application.
Basic Usage
The basic structure for using an icon from our sprite is:
<svg>
<use href={`/icons/sprite.svg#${name}`} />
</svg>
Here, name is the icon's identifier. If you used subfolders in your icon organization, the name would include the folder, e.g., radix/camera.
Creating a Reusable Icon Component
To make our icons more versatile and type-safe, we'll create a reusable Icon component:
Create a new file components/Icon.tsx:
import { type SVGProps } from "react"
import { type IconName } from "@/types/name"
import { cn } from "@/lib/cn"
export { IconName }
export function Icon({
name,
childClassName,
className,
children,
...props
}: SVGProps<SVGSVGElement> & {
name: IconName
childClassName?: string
}) {
if (children) {
return (
<span
className={cn(`inline-flex items-center font gap-1.5`, childClassName)}
>
<Icon name={name} className={className} {...props} />
{children}
</span>
)
}
return (
<svg
{...props}
className={cn("inline self-center w-[1em] h-[1em]", className)}
>
<use href={`./icons/sprite.svg#${name}`} />
</svg>
)
}
This component:
- • Uses TypeScript for type safety
- • Allows passing SVG props and additional custom props
- • Supports rendering the icon alone or with accompanying text
- • Uses a utility function cn for class name management
Create the cn utility function in lib/cn.ts:
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
This function combines clsx and tailwind-merge for efficient class name handling.
Using the Icon Component
Now you can use the Icon component in your Next.js application:
Basic usage:
<Icon className="h-4 w-4" name="radix/camera" />
With accompanying text:
<Icon className="h-4 w-4" name="radix/pencil-1" childClassName="text-red-600">
Auto aligned text, with icon on left
</Icon>
This setup allows for flexible and efficient use of your SVG icons throughout your Next.js application, with the benefits of TypeScript for type safety and Tailwind CSS for styling.
Preloading the Sprite
To ensure that our SVG sprite is loaded and ready to use as soon as the page loads, we'll implement a preloading mechanism. This is especially useful for improving the initial load performance of our application.
Creating a Preload Component
Create a new file called preload-resources.tsx in your components directory:
"use client"
import ReactDOM from "react-dom"
export function PreloadResources() {
ReactDOM.preload("/icons/sprite.svg", {
as: "image",
})
return null
}
This component:
- • Is marked with "use client" directive, indicating it's a Client Component in Next.js.
- • Uses ReactDOM.preload() to preload the sprite file.
- • The as: "image" option tells the browser to preload the file as an image resource.
- • Returns null because it doesn't render any visible content.
Using the Preload Component
To use this preload component, we need to add it to our top-level layout file.
Open your app/layout.tsx file (or create it if it doesn't exist):
import { PreloadResources } from '../components/preload-resources'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
<PreloadResources />
</head>
<body>{children}</body>
</html>
)
}
This setup:
- • Imports the PreloadResources component.
- • Places it within the <head> tag of the HTML document.
- • Ensures that the sprite preloading occurs for every page in your application.
Benefits of Preloading
By preloading the SVG sprite:
- • The browser starts downloading the sprite file as soon as possible.
- • Icons are likely to be available immediately when needed, reducing or eliminating any delay in icon rendering.
- • It improves the perceived performance of your application, especially for users with slower internet connections.
Remember that while preloading can improve performance, it's also important to balance this with other resources that might need to be loaded. Always test the performance impact in your specific use case.
Sly Configuration
If you're using Sly to manage your SVG files, you can create a configuration file to automate the process of adding icons and running the build script. Here's an explanation of the example sly.json configuration:
Create a sly.json file in your project root:
{
"$schema": "https://sly-cli.fly.dev/registry/config.json",
"libraries": [
{
"name": "@radix-ui/icons",
"directory": "./other/svg/radix",
"postinstall": ["pnpm", "run", "build:icons"],
"transformers": []
},
{
"name": "lucide-icons",
"directory": "./other/svg/lucide",
"postinstall": ["pnpm", "run", "build:icons"],
"transformers": []
}
]
}
Certainly! Let's break down the Sly configuration for managing SVG icons:
Sly Configuration
If you're using Sly to manage your SVG files, you can create a configuration file to automate the process of adding icons and running the build script. Here's an explanation of the example sly.json configuration:
Create a sly.json file in your project root:
jsonCopy{ "$schema": "https://sly-cli.fly.dev/registry/config.json", "libraries": [ { "name": "@radix-ui/icons", "directory": "./other/svg/radix", "postinstall": ["pnpm", "run", "build:icons"], "transformers": [] }, { "name": "lucide-icons", "directory": "./other/svg/lucide", "postinstall": ["pnpm", "run", "build:icons"], "transformers": [] } ] }
Let's break down this configuration:
- • $schema: This points to the JSON schema for Sly, ensuring your configuration is valid.
- • libraries: An array of icon libraries you want to manage with Sly.
For each library:
- • name: The name of the icon library package.
- • directory: Where the SVG files for this library should be stored in your project.
- • postinstall: Commands to run after installing icons. Here, it runs the build:icons script using pnpm.
- • transformers: Any transformations to apply to the SVGs (empty in this case).
Benefits of this Configuration:
Automation: Whenever you add new icons using Sly, it will automatically place them in the correct directory and run the build script.
Consistency: Ensures all team members use the same directory structure and build process for icons.
Multiple Libraries: You can easily manage icons from different libraries (like Radix UI and Lucide) in separate directories.
Post-install Hook: The build:icons script runs automatically after adding new icons, keeping your sprite up-to-date.
Using this Configuration:
With this sly.json in place, you can add icons using Sly commands, and it will handle the file placement and build process automatically. For example:
npx @sly-cli/sly add @radix-ui/icons:camera
This would add the camera icon from Radix UI to ./other/svg/radix and then run the build:icons script to update your sprite.
This configuration streamlines the process of managing and building your SVG sprite, making it easier to maintain a large set of icons from multiple libraries in your Next.js project.