React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

svgiconssvgraccessibilitycustom-icons

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 currentColor value makes SVG icons inherit the parent element's color CSS property, enabling styling with Tailwind text color classes.
  • The viewBox="0 0 24 24" attribute defines the coordinate space. The width and height attributes control the rendered size independently.
  • The createIcon factory 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" with aria-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 Omit utility to remove children from the SVG props since icon content is fixed.
  • For SVGR, add a type declaration file so TypeScript understands .svg imports.
// 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 viewBox attribute when optimizing SVGs with SVGO. Removing it breaks scaling behavior.
  • The fill and stroke properties default to black in SVG. Set fill="none" for stroke-based icons or stroke="none" for filled icons explicitly.
  • SVGR adds a title element by default, which creates a tooltip on hover. Disable it with titleProp: false in SVGR config if unwanted.
  • SVG sprites loaded via use href do not support CSS currentColor for 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

ApproachProsCons
Inline SVG componentsFull control, tree-shakeable, CSS stylingVerbose, increases bundle per icon
SVGR importsUse .svg files directly, auto-optimizationRequires webpack config, build step
SVG spritesSingle request for all icons, cachedNo tree-shaking, manual sprite management
Icon fontsTiny bundle, familiar CSS usageBlurry at small sizes, limited styling
Icon libraries (Lucide)Ready-made, consistent, well-maintainedExternal 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 .svg files 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 CSS color property.
  • 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, and className props.
  • It avoids repeating boilerplate SVG attributes across every icon.
  • Each icon gets a displayName for React DevTools.
How do you handle accessibility for decorative vs. informative SVG icons?
  • Decorative icons: set aria-hidden="true" and omit role.
  • Informative icons: set role="img" and provide aria-label with a description.
  • The example checks whether a label prop is provided to decide.
Gotcha: What happens if you remove viewBox when optimizing SVGs with SVGO?
  • Removing viewBox breaks scaling behavior entirely.
  • The icon will not resize correctly with width/height changes.
  • 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">.
  • currentColor does 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: strokeWidth not stroke-width, viewBox not viewbox.
  • 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.