Custom SVG Icons in React
Recipe
Create reusable, accessible SVG icon components in React. Use inline SVG for full control, SVGR for importing .svg files as components, or an SVG sprite for optimal performance with many icons.
Inline SVG as a component:
// components/icons/check-icon.tsx
interface IconProps {
size?: number;
color?: string;
className?: string;
"aria-label"?: string;
}
export function CheckIcon({ size = 24, color = "currentColor", className, ...props }: IconProps) {
const isDecorative = !props["aria-label"];
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
role={isDecorative ? undefined : "img"}
aria-hidden={isDecorative ? true : undefined}
aria-label={props["aria-label"]}
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}Working Example
A custom icon set component library with consistent API and accessibility:
// components/icons/icon.tsx
import { type SVGProps } from "react";
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "children"> {
size?: number;
label?: string;
}
function createIcon(path: React.ReactNode, displayName: string) {
function Icon({ size = 24, label, className, ...props }: IconProps) {
const isDecorative = !label;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
role={isDecorative ? undefined : "img"}
aria-hidden={isDecorative ? true : undefined}
aria-label={label}
{...props}
>
{path}
</svg>
);
}
Icon.displayName = displayName;
return Icon;
}
// Define icons
export const ArrowRight = createIcon(
<line x1="5" y1="12" x2="19" y2="12"><polyline points="12 5 19 12 12 19" /></line>,
"ArrowRight"
);
export const Close = createIcon(
<><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></>,
"Close"
);
export const Heart = createIcon(
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />,
"Heart"
);
export const Star = createIcon(
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
"Star"
);
export const Search = createIcon(
<><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></>,
"Search"
);
export const Menu = createIcon(
<><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" /></>,
"Menu"
);// app/components/icon-showcase.tsx
"use client";
import { useState } from "react";
import { ArrowRight, Close, Heart, Star, Search, Menu } from "@/components/icons/icon";
import type { IconProps } from "@/components/icons/icon";
const icons = [
{ component: ArrowRight, name: "ArrowRight" },
{ component: Close, name: "Close" },
{ component: Heart, name: "Heart" },
{ component: Star, name: "Star" },
{ component: Search, name: "Search" },
{ component: Menu, name: "Menu" },
];
export function IconShowcase() {
const [iconSize, setIconSize] = useState(24);
const [iconColor, setIconColor] = useState("#374151");
return (
<div className="space-y-6">
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
Size:
<input
type="range"
min={16}
max={48}
value={iconSize}
onChange={(e) => setIconSize(Number(e.target.value))}
className="w-32"
/>
<span className="w-8 text-right">{iconSize}</span>
</label>
<label className="flex items-center gap-2 text-sm">
Color:
<input
type="color"
value={iconColor}
onChange={(e) => setIconColor(e.target.value)}
className="h-8 w-8 cursor-pointer"
/>
</label>
</div>
<div className="grid grid-cols-3 gap-4 sm:grid-cols-6">
{icons.map(({ component: Icon, name }) => (
<div
key={name}
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4"
>
<Icon size={iconSize} color={iconColor} label={name} />
<span className="text-xs text-gray-500">{name}</span>
</div>
))}
</div>
</div>
);
}Deep Dive
How It Works
- Inline SVG renders directly in the DOM, allowing full CSS and JavaScript control over stroke, fill, animations, and hover states.
- The
currentColorvalue makes SVG icons inherit the parent element'scolorCSS property, enabling styling with Tailwind text color classes. - The
viewBox="0 0 24 24"attribute defines the coordinate space. Thewidthandheightattributes control the rendered size independently. - The
createIconfactory function keeps the API consistent across all icons while avoiding repeated boilerplate. - For accessibility, icons are either decorative (
aria-hidden="true") or informative (role="img"witharia-label).
Variations
SVGR for importing .svg files as React components:
npm install @svgr/webpack// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: [
{
loader: "@svgr/webpack",
options: {
svgoConfig: {
plugins: [{ name: "removeViewBox", active: false }],
},
},
},
],
});
return config;
},
};
export default nextConfig;// Now import SVGs as components
import Logo from "@/public/icons/logo.svg";
export function Header() {
return <Logo className="h-8 w-8 text-blue-600" />;
}SVG sprite approach for many icons:
// public/icons/sprite.svg
// <svg xmlns="http://www.w3.org/2000/svg">
// <symbol id="icon-check" viewBox="0 0 24 24">
// <polyline points="20 6 9 17 4 12" />
// </symbol>
// <symbol id="icon-close" viewBox="0 0 24 24">
// <line x1="18" y1="6" x2="6" y2="18" />
// <line x1="6" y1="6" x2="18" y2="18" />
// </symbol>
// </svg>
interface SpriteIconProps {
name: string;
size?: number;
className?: string;
label?: string;
}
export function SpriteIcon({ name, size = 24, className, label }: SpriteIconProps) {
return (
<svg
width={size}
height={size}
className={className}
role={label ? "img" : undefined}
aria-hidden={label ? undefined : true}
aria-label={label}
>
<use href={`/icons/sprite.svg#icon-${name}`} />
</svg>
);
}
// Usage: <SpriteIcon name="check" size={20} className="text-green-500" />TypeScript Notes
- Extend
SVGProps<SVGSVGElement>to allow all native SVG attributes on your icon components. - Use the
Omitutility to removechildrenfrom the SVG props since icon content is fixed. - For SVGR, add a type declaration file so TypeScript understands
.svgimports.
// types/svg.d.ts
declare module "*.svg" {
import type { FC, SVGProps } from "react";
const content: FC<SVGProps<SVGSVGElement>>;
export default content;
}Gotchas
- SVG attributes in JSX use camelCase (
strokeWidth,viewBox,fillRule) instead of kebab-case. React will warn about invalid DOM properties otherwise. - Always preserve the
viewBoxattribute when optimizing SVGs with SVGO. Removing it breaks scaling behavior. - The
fillandstrokeproperties default toblackin SVG. Setfill="none"for stroke-based icons orstroke="none"for filled icons explicitly. - SVGR adds a
titleelement by default, which creates a tooltip on hover. Disable it withtitleProp: falsein SVGR config if unwanted. - SVG sprites loaded via
use hrefdo not support CSScurrentColorfor cross-origin sprites. The sprite must be on the same domain. - Inline SVGs increase HTML document size. For pages with hundreds of icons, consider sprites or an icon font instead.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Inline SVG components | Full control, tree-shakeable, CSS styling | Verbose, increases bundle per icon |
| SVGR imports | Use .svg files directly, auto-optimization | Requires webpack config, build step |
| SVG sprites | Single request for all icons, cached | No tree-shaking, manual sprite management |
| Icon fonts | Tiny bundle, familiar CSS usage | Blurry at small sizes, limited styling |
| Icon libraries (Lucide) | Ready-made, consistent, well-maintained | External dependency, less control |
FAQs
What are the three main approaches to using custom SVG icons in React?
- Inline SVG components -- write SVG markup directly in a React component for full control.
- SVGR -- import
.svgfiles as React components via a webpack loader. - SVG sprites -- reference icons from a single sprite file using
<use href>.
How does currentColor make SVG icons work with Tailwind text color classes?
- Setting
stroke="currentColor"makes the icon inherit the parent's CSScolorproperty. - Apply
className="text-blue-500"on the parent or icon, and the stroke color updates automatically.
What does the createIcon factory function do in the example?
- It wraps SVG path data in a consistent component with
size,label, andclassNameprops. - It avoids repeating boilerplate SVG attributes across every icon.
- Each icon gets a
displayNamefor React DevTools.
How do you handle accessibility for decorative vs. informative SVG icons?
- Decorative icons: set
aria-hidden="true"and omitrole. - Informative icons: set
role="img"and providearia-labelwith a description. - The example checks whether a
labelprop is provided to decide.
Gotcha: What happens if you remove viewBox when optimizing SVGs with SVGO?
- Removing
viewBoxbreaks scaling behavior entirely. - The icon will not resize correctly with
width/heightchanges. - Configure SVGO with
{ name: "removeViewBox", active: false }.
How do you set up SVGR to import .svg files as React components in Next.js?
// next.config.ts
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: [{ loader: "@svgr/webpack" }],
});
return config;
}Then: import Logo from "@/public/icons/logo.svg";
What TypeScript declaration is needed for .svg file imports with SVGR?
// types/svg.d.ts
declare module "*.svg" {
import type { FC, SVGProps } from "react";
const content: FC<SVGProps<SVGSVGElement>>;
export default content;
}How does the SVG sprite approach work, and what is its limitation with currentColor?
- Icons are defined as
<symbol>elements in a single SVG file. - Components reference them with
<use href="/icons/sprite.svg#icon-name">. currentColordoes not work for cross-origin sprites; the sprite must be on the same domain.
Gotcha: What are the default values for fill and stroke in SVG?
- Both default to
black. - For stroke-based icons, explicitly set
fill="none". - For filled icons, explicitly set
stroke="none". - Forgetting this causes unexpected black fills or strokes.
Why do SVG attributes use camelCase in JSX?
- React requires camelCase for DOM properties:
strokeWidthnotstroke-width,viewBoxnotviewbox. - Using kebab-case triggers React warnings about invalid DOM properties.
When should you use SVG sprites instead of inline SVG components?
- When a page uses hundreds of icons, inline SVGs increase HTML document size significantly.
- Sprites load all icons in a single request and are cached by the browser.
- Trade-off: sprites do not support tree-shaking.
How do you type an icon component prop that extends all SVG attributes?
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "children"> {
size?: number;
label?: string;
}Use Omit to remove children since icon content is fixed.