ICON OOP
Icons & Logos
Guide

SVG icons in React

React makes inline SVG practical, because the duplication argument disappears once an icon is a component. What it also does is create four new ways to get icons wrong, and three of them only show up in production.

HomeGuidesSVG icons in React

There are four ways to put an SVG icon in a React app, and they are not equivalent. Which you should use depends on how many icons you have, whether they need to be selected at runtime, and whether you are rendering on the server.

The short answer

Use the official package for your icon set (lucide-react, @tabler/icons-react, @phosphor-icons/react) for anything real. Hand-write components only for a handful of custom icons. Use a sprite when icons are chosen at runtime from data. Avoid <img> for interface icons, because you lose colour control.

JSX attribute traps

Pasting raw SVG into JSX fails, and the error messages are not always clear about why. Four things need converting.

HTMLJSX
stroke-widthstrokeWidth
stroke-linecapstrokeLinecap
fill-rule, clip-rulefillRule, clipRule
classclassName
xlink:hrefxlinkHref, or just href

Two exceptions worth remembering because they break the pattern: viewBox keeps its capital B and is not converted, and data and aria attributes stay hyphenated. So aria-hidden stays as it is.

Inline styles also change shape. style="fill: red" becomes style={{ fill: 'red' }}, taking an object rather than a string. In practice you should be using currentColor and CSS classes instead of inline styles anyway.

Writing an icon component properly

A good icon component does three things: defaults its size, forwards unknown props, and hides itself from assistive technology by default.

export function SearchIcon({ size = 20, ...props }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      <circle cx="11" cy="11" r="7" />
      <path d="M21 21l-4.3-4.3" />
    </svg>
  );
}

The {...props} spread is what makes the component usable rather than merely functional. It lets a caller pass className, onClick, data-testid or an aria-label without you having to anticipate each one. Put it last so callers can override your defaults, including aria-hidden when the icon does need to be announced.

Keeping stroke="currentColor" rather than accepting a color prop is deliberate. Colour then comes from CSS, which means hover, focus, disabled and dark mode all work with no prop threading at all.

Using an icon package

import { Search, Settings, ChevronDown } from 'lucide-react';

<Search size={20} />
<Settings size={20} strokeWidth={1.5} />
<ChevronDown size={16} className="text-gray-400" />

Two props worth knowing about in lucide-react. strokeWidth does what you expect. absoluteStrokeWidth keeps the rendered stroke at a fixed pixel width regardless of icon size, which solves the problem where a 2px stroke looks spindly at 48px and heavy at 14px. Without it, browsers scale stroke proportionally, which is visually the wrong behaviour. Background in icon sizes.

Phosphor's React package offers a context provider, which is genuinely valuable with a multi-weight set:

<IconContext.Provider value={{ size: 20, weight: 'regular' }}>
  <App />
</IconContext.Provider>

Every icon below inherits those defaults, so switching the whole product from regular to bold is one line. See Phosphor.

Bundle size and tree shaking

The fear is that importing from a 5,000 icon package ships 5,000 icons. With a modern bundler and named imports, it does not: each icon is a separate module and unused ones are eliminated.

What breaks tree shaking, and it is worth checking because it silently costs hundreds of kilobytes:

  • Namespace imports. import * as Icons from 'lucide-react' pulls in everything. Always use named imports.
  • Building a lookup object. Importing fifty icons into a map so you can select by string reference forces all fifty into the bundle, whether or not any are rendered.
  • CommonJS builds. Tree shaking needs ES modules. If your bundler resolves the package's CJS entry, nothing is eliminated.
  • Re-exporting through a barrel file without sideEffects: false configured, which can defeat elimination depending on your setup.

The practical check: build, then look at your bundle analyser output for the icon package. If it is measured in hundreds of kilobytes rather than a few, one of the above is happening.

Dynamic icons chosen at runtime

This is the case where components stop being the right tool. If your icon name comes from an API, a CMS or a config file, you cannot statically import it, and every workaround has a cost.

The explicit map. Import only the icons you actually support and map them:

import { Search, Settings, User } from 'lucide-react';

const ICONS = { search: Search, settings: Settings, user: User };

function Icon({ name, ...props }) {
  const Cmp = ICONS[name];
  return Cmp ? <Cmp {...props} /> : null;
}

Honest and predictable. Every icon in the map ships, so keep the map to icons you genuinely use.

The sprite. For a genuinely open-ended set, a sprite is better than any React solution:

function Icon({ name, size = 20, ...props }) {
  return (
    <svg width={size} height={size} aria-hidden="true" {...props}>
      <use href={`/icons.svg#${name}`} />
    </svg>
  );
}

One cached request covers every icon, the JS bundle carries none of them, and currentColor still works. The trade is a build step to generate the sprite, and the sprite must be same-origin because <use> is blocked cross-origin.

What to avoid is dynamic import() per icon. It works, and it produces a network request per icon and visible pop-in as each resolves.

SVGR and importing .svg files

Most React toolchains let you import an SVG file as a component, usually via SVGR:

import SearchIcon from './icons/search.svg?react';   // Vite
import { ReactComponent as SearchIcon } from './icons/search.svg';  // CRA

Convenient for custom icons that live in your repo. Three things to configure or you will hit problems later:

  • Turn on SVGO in the SVGR config, but configure it to preserve viewBox and currentColor. SVGO's defaults strip the first and can inline the second, which breaks scaling and theming. See optimising SVG.
  • Enable ID prefixing. Multi-colour SVGs contain gradient and clip path IDs; two icons with the same ID on one page collide and render wrongly. This bug is intermittent and unpleasant to diagnose.
  • Check the syntax for your bundler. The import form differs between Vite, Next.js, Create React App and Webpack, and the wrong one fails confusingly.

Server components and hydration

Icons are static markup with no state, no effects and no event handlers, which makes them ideal server components in Next.js App Router and similar frameworks. Leave them out of your "use client" boundary and they render on the server with zero JavaScript shipped for them.

The common mistake is putting "use client" at the top of a shared icon module. Every icon then becomes a client component, and the whole set is pulled into the client bundle even though not one of them needs interactivity. Icon components should almost never be client components; the interactive thing is the button around the icon, not the icon.

Hydration mismatches with icons are rare, because the markup is deterministic. If you see one, look for a randomly generated ID, which some SVG tooling adds for gradients and clip paths, generating a different value on server and client. Use a deterministic prefix instead.

Accessibility in React

The rules are the same as plain HTML, but the component boundary makes it easy to get wrong in a systematic way.

{/* Icon-only button: name the BUTTON, hide the icon */}
<button aria-label="Search">
  <SearchIcon aria-hidden="true" />
</button>

{/* Icon with visible text: hide the icon */}
<button>
  <SearchIcon aria-hidden="true" /> Search
</button>

{/* Icon carrying meaning alone */}
<StatusIcon role="img" aria-label="Connected" />

Default aria-hidden="true" in your icon components, since the large majority of icons are decorative or paired with text. The spread lets callers override it in the rare case where the icon itself carries the meaning. Full patterns in accessible icons.

Frequently asked questions

Either paste the SVG into a component and convert the attributes to JSX form, using strokeWidth rather than stroke-width and className rather than class while keeping viewBox capitalised, or install the official package for your icon set and import icons by name. Packages are the better choice for anything beyond a handful of custom icons, since they are tree shaken and consistently typed.
Not with named imports and a modern bundler, since each icon is a separate module and unused ones are eliminated. Tree shaking breaks if you use a namespace import, build a lookup object containing many icons, or if your bundler resolves the package's CommonJS build rather than its ES module build. Check your bundle analyser output to confirm.
Import the icons you support into an explicit map and look them up by key, accepting that every icon in the map ships. For a genuinely open-ended set, use an SVG sprite and reference symbols with the use element, which keeps icons out of the JavaScript bundle entirely while preserving currentColor styling. Avoid dynamic import per icon, which causes a request and visible pop-in for each one.
No, in almost all cases. Icons are static markup with no state or event handlers, so they render perfectly as server components with no JavaScript shipped. Putting a "use client" directive at the top of a shared icon module is a common mistake that pulls your entire icon set into the client bundle unnecessarily.
Usually an ID collision. Multi-colour SVGs define gradients and clip paths using IDs, and when two icons on the same page share an ID, one overrides the other. Enable ID prefixing in your SVGR or SVGO configuration. If icons render as solid blobs instead, a fill has been applied to a stroke-based icon that needs fill set to none.
Generally no. Keep stroke or fill set to currentColor and let CSS drive the colour through the color property, which inherits. That way hover, focus, disabled and dark mode states all work without threading a colour prop through your component tree, and your icons automatically match the text they sit beside.

Copy an icon as a React component

Pick any icon, set the stroke and colour, and copy ready-to-paste JSX. Free, no npm required.

Open the ICON OOP tool