The most reliable way to use SVG icons in React is to make each icon a small component that returns its <svg> as JSX, draws in currentColor and takes a size prop. The icon then takes its colour from the text around it, renders on the server, and is in the HTML on first paint without an extra request.
Reach for SVGR when you would rather import .svg files than paste path data, a sprite with <use> when one page shows hundreds of icons, and a plain <img> only for artwork that never changes colour. Every snippet below targets React 19 and was checked against the current docs for Vite, Next.js 16 and react-native-svg.
Four ways, side by side
| Colour from text | Second colour | Bundle cost | Cached file | Best for | |
|---|---|---|---|---|---|
| Inline component (hand-written or SVGR) | Yes | Yes | Paths in JS | No | Most apps |
Sprite + <use> | Yes | No | One short tag each | Yes | Hundreds of icons per page |
<img> or next/image | No | No | None | Yes | Logos, fixed-colour art |
| CSS mask | Yes | No | None | Yes | Decorative icons set in CSS |
The first row is the default for good reason, so most of this guide is about getting it right.
A typed icon component
Write the <svg> wrapper once and let each icon supply only its paths. The wrapper owns size, stroke and accessibility, and every icon picks them up for free.
// components/icons/Icon.tsx
import type { ComponentProps } from "react";
export type IconProps = ComponentProps<"svg"> & {
size?: number | string;
/** A name for icons that carry meaning on their own. Leave it out for decorative icons. */
label?: string;
};
export function Icon({ size = 24, label, children, ...props }: IconProps) {
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"
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
{...props}
>
{children}
</svg>
);
}
// components/icons/HeartIcon.tsx
import { Icon, type IconProps } from "./Icon";
export function HeartIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M12 21C13 21 22 15.98 22 8.94C22 3.51 15.16 0.66 12 5.43C8.83 0.65 2 3.5 2 8.94C2 15.98 11 21 12 21Z" />
</Icon>
);
}
Stroke and fill are inherited in SVG, so the path picks up stroke="currentColor" and the 2px width from the root. Any prop you pass lands on the <svg> through the spread, which means <HeartIcon strokeWidth={1.5} className="like" /> works with no extra code. For solid icons, pass fill="currentColor" stroke="none" or give them a second wrapper with those defaults.
The spread also carries ref. Since React 19, a ref is an ordinary prop on function components, so the wrapper needs no forwardRef.
<button type="button">
<HeartIcon size={20} />
Save
</button>
Importing .svg files with SVGR
If your icons arrive as .svg files from a designer, SVGR converts each one into a React component at build time. The setup differs by bundler, and the import syntax differs with it.
Vite
Install vite-plugin-svgr and add it next to the React plugin. Files imported with the ?react suffix become components; everything else keeps Vite's default, which returns the file's URL.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
export default defineConfig({
plugins: [react(), svgr({ svgrOptions: { icon: true } })],
});
import HeartIcon from "./icons/heart.svg?react";
<HeartIcon className="like" aria-hidden="true" />;
For TypeScript, add /// <reference types="vite-plugin-svgr/client" /> to vite-env.d.ts, as the plugin's README shows. Without it, the import fails with "Cannot find module './icons/heart.svg?react' or its corresponding type declarations."
Next.js 16 with Turbopack
Next.js 16 uses Turbopack by default for both next dev and next build, so a webpack() function in your config no longer applies. Turbopack runs @svgr/webpack as a loader instead, configured under turbopack.rules as the Next.js Turbopack docs show:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.svg": {
loaders: [{ loader: "@svgr/webpack", options: { icon: true } }],
as: "*.js",
},
},
},
};
export default nextConfig;
import HeartIcon from "./icons/heart.svg";
export default function Page() {
return <HeartIcon aria-hidden="true" />;
}
The rule turns every imported .svg into a component, so an imported SVG can no longer go straight to next/image. Files in public/ that you reference by path, like src="/logo.svg", aren't imported and keep working. Next's built-in types declare *.svg imports as any, so this compiles, but props go unchecked until you add a declaration file like the one in SVGR's Next.js guide.
Create React App
Create React App was deprecated for new apps in February 2025. Its built-in syntax, import { ReactComponent as Logo } from "./logo.svg", is what most older tutorials show, and it doesn't work in Vite or Next.js. If you are moving off it, the Vite setup above is the closest match.
The viewBox that SVGO deletes
Here is a trap that is easy to miss. @svgr/webpack runs SVGO by default, and SVGO 3's default preset includes removeViewBox, which deletes the viewBox whenever it matches the file's width and height. A 24×24 icon file nearly always matches, so its viewBox is gone.
The icon looks fine at 24px. Pass width={48} and the drawing stays 24px in the top-left corner of a 48px box, because without a viewBox there is nothing to scale it by.
SVGR's icon option fixes this: it keeps the viewBox and sets width and height to 1em, so the icon also scales with font size. dimensions: false keeps it too. vite-plugin-svgr doesn't run SVGO unless you add @svgr/plugin-svgo, so Vite users only meet the trap once they opt into optimisation.
Colour comes from the text
Draw icons in currentColor and set the colour on the element around them. A button then colours its label and its icon with one declaration, and hover, disabled and dark-mode states follow without any icon-specific CSS.
.like {
color: #6b6b6b;
}
.like:hover {
color: #e5484d;
}
When an icon refuses to change colour, the file has a colour written into it, usually fill="#000" on a path. Replace it with currentColor in the source, or let SVGR do it at build time with replaceAttrValues: { "#000": "currentColor" }. An icon loaded through <img> or background-image can't see your text colour at all, whatever its file says.
Duotone icons are the reason to stay inline. Pikaicons draws the second tone as a separate path with opacity="0.28" in the same currentColor, so it follows your text colour too, and an inline component can recolour that path on its own. The duotone icons guide shows how.
Size, and the stroke that grows with it
Give icons a fixed size from your scale in buttons and toolbars, and 1em when they sit inside running text so a heading's icon grows with the heading. The icon sizes guide covers which sizes to pick.
Two layout details save a lot of confusion. An <svg> is inline by default, so it sits on the text baseline and leaves a few pixels of space under it; set display: block when it's alone in a box. In a flex row with long text, the icon is the first thing to shrink, so give it flex-shrink: 0.
A stroke scales with the icon, so a 2px stroke drawn on a 24px grid becomes 4px at 48px. To keep it at 2px, pass strokeWidth={(2 * 24) / size}, or set vector-effect="non-scaling-stroke" on the paths.
Icons a screen reader should hear
Most icons sit beside a text label and should be hidden from assistive technology so the label isn't read twice. The wrapper above does that by default with aria-hidden.
An icon that is the only content of a button needs a name, and the button is the place for it:
<button type="button" aria-label="Add to favourites">
<HeartIcon />
</button>
An icon that carries meaning on its own outside a control, such as a status marker in a table, takes the label prop. The wrapper then renders role="img" with an aria-label, which MDN documents under the img role.
<HeartIcon label="Favourite" />
Sprites for pages with hundreds of icons
A sprite is one SVG file holding many icons, each a <symbol> with an id and its own viewBox. The page draws an icon by reference, so a table with 500 rows of icons repeats a one-line tag instead of 500 copies of the path data.
<!-- public/icons/sprite.svg -->
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="heart" viewBox="0 0 24 24" fill="none">
<path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
d="M12 21C13 21 22 15.98 22 8.94C22 3.51 15.16 0.66 12 5.43C8.83 0.65 2 3.5 2 8.94C2 15.98 11 21 12 21Z" />
</symbol>
</svg>
import type { ComponentProps } from "react";
// Generate this union from the sprite so a typo fails the type check.
export type IconName = "heart" | "search-default" | "settings-01";
type SpriteIconProps = ComponentProps<"svg"> & { name: IconName; size?: number };
export function SpriteIcon({ name, size = 24, ...props }: SpriteIconProps) {
return (
<svg width={size} height={size} aria-hidden="true" {...props}>
<use href={`/icons/sprite.svg#${name}`} />
</svg>
);
}
The sprite downloads once and the browser caches it. The free icon grid on this site works this way, with one sprite per style.
currentColor still works, because the copied content inherits its colour from the <use> element. What you lose is per-part styling: the copy is hidden from page CSS, so you can set its colour but not repaint one path. MDN also warns that browsers may refuse a cross-origin href, so serve the sprite from your own origin rather than a CDN on another domain.
When an <img> is enough
<img src="/logo.svg" width={120} height={32} alt="Acme" />
An <img> costs nothing in your bundle and is cached, but the SVG inside is sealed off from the page. That suits logos and illustrations and fails interface icons that need hover, disabled and dark-mode states.
next/image works the same way. Next.js skips optimisation automatically when src ends in .svg, so you don't need dangerouslyAllowSVG for a static file.
A CSS mask paints the SVG's shape in any colour, currentColor included, from an empty <span>:
.icon-heart {
width: 24px;
height: 24px;
background-color: currentColor;
mask: url("/icons/heart.svg") center / contain no-repeat;
}
The mask works from transparency, so a duotone icon's lighter layer still shows lighter in the same colour. The span is invisible to anything that reads the DOM, so keep masks for decoration.
What icons cost your bundle
On this site's free stroke icons, one icon averages about 800 bytes of markup before compression and under 200 bytes gzipped. By that measure, fifty inline icons add less than 10 KB to a compressed bundle.
The cost that hurts is importing icons you don't use. Import each icon by name, and avoid import * as Icons followed by Icons[name]: a lookup by string means the bundler can't tell which icons you need. Lucide's docs say its by-name DynamicIcon imports all icons during the build and recommend direct imports for anything static.
Server Components take the JavaScript cost away entirely. An icon component with no hooks or event handlers is a valid Server Component as written, and its code never reaches the browser, only the rendered <svg>. Import it into a file marked "use client" and it joins the client bundle along with everything else that file imports.
React Native
React Native has no DOM, so <svg> doesn't exist there. react-native-svg provides the same elements as components; install it with npx expo install react-native-svg in an Expo app.
import Svg, { Path, type SvgProps } from "react-native-svg";
type IconProps = SvgProps & { size?: number };
export function HeartIcon({ size = 24, color = "black", ...props }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" color={color} {...props}>
<Path
d="M12 21C13 21 22 15.98 22 8.94C22 3.51 15.16 0.66 12 5.43C8.83 0.65 2 3.5 2 8.94C2 15.98 11 21 12 21Z"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
There is no CSS cascade on native, so colour doesn't flow down from a parent <Text>. The color prop on <Svg> sets the value that currentColor refers to, as the library's usage guide describes. To import .svg files as components, the same guide points to react-native-svg-transformer.
When it breaks
| Symptom | Cause | Fix |
|---|---|---|
| "Element type is invalid … but got: object" in Next.js | Without SVGR, an .svg import is image data, not a component | Add the Turbopack rule above, or use it with next/image |
| A Vite import renders nothing or throws | Without ?react, the import is a URL string | Import ./icon.svg?react |
"Invalid DOM property stroke-width. Did you mean strokeWidth?" | SVG pasted straight from a file | Camel-case the attributes, class becomes className, and a style string becomes an object |
| Icon stays small or gets cropped when resized | The viewBox is missing | Use SVGR's icon: true or restore viewBox="0 0 24 24" |
| Colour won't change | A hard-coded fill or stroke, or the icon is an <img> | Switch to currentColor and render inline |
| Icon squashed in a row | Flex shrinking | flex-shrink: 0 on the icon |
| Sprite icon is blank | Cross-origin sprite, wrong id, or a <symbol> without a viewBox | Serve it same-origin and check the id and viewBox |
| One icon's gradient shows up in another | Two pasted icons both use id="a" | Rename the ids, or generate them with useId. SVGR's SVGO step prefixes them, but vite-plugin-svgr skips SVGO by default |
Avoid fetching SVG strings and rendering them with dangerouslySetInnerHTML unless you made the files. An SVG can carry scripts and event handlers, and React's docs warn that untrusted markup there is an XSS hole.
Copying icons in this shape
Every free Pikaicon copies as JSX with currentColor already in place and the attributes in React's camelCase. Here is exactly what you get for the heart:
<svg xmlns="http://www.w3.org/2000/svg" width={24} height={24} viewBox="0 0 24 24" fill="none">
{/*
Pikaicons: heart (Stroke), free for personal and commercial use (https://pikaicons.com/free-icons)
Credit is optional but appreciated: "Icons by Pikaicons" linking to https://pikaicons.com
License: https://pikaicons.com/license
*/}
<path transform="translate(2 3)" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M10 18C11 18 20 12.98 20 5.94C20 0.51 13.16 -2.34 10 2.43C6.83 -2.35 0 0.5 0 5.94C0 12.98 9 18 10 18Z"/>
</svg>
To turn it into a component, keep the <path> and put it inside the Icon wrapper from earlier, or swap the fixed width and height for a size prop and add {...props}. The comment at the top says where the icon came from; it's a courtesy, and you can delete it.
Browse the 500+ free icons to copy one; they're free for commercial use. The Pikaicons app holds all 5,000+ icons across five styles and copies them as SVG, React, Vue, Svelte, React Native or Solid, with the size and stroke width set before you copy, for a one-time $99. There is no npm package, so the icons you copy live in your codebase as plain components you own; if you would rather install a package, the icon library roundup compares the ones that ship one.
Frequently asked questions
How do I change the colour of an SVG icon in React?
Draw the icon with stroke="currentColor" or fill="currentColor" and set CSS color on the icon or its parent. If the colour won't change, look for a hard-coded fill or stroke in the file, and make sure the icon is inline rather than loaded through <img>.
How do I import an SVG as a React component in Next.js?
Install @svgr/webpack and add a turbopack.rules entry for *.svg with as: "*.js" in next.config.ts, as shown above. Next.js 16 builds with Turbopack by default, so older webpack() configs from tutorials no longer apply.
Should I use SVGR, an icon package or hand-written components?
An npm icon package is the quickest start when its set covers your needs. SVGR suits your own .svg files, and hand-written components suit icons you copy in once and keep, which is how Pikaicons is meant to be used.
Do SVG icons work in React Server Components?
Yes. An icon component without hooks or event handlers renders on the server and sends only its markup to the browser; it becomes client code only when a "use client" file imports it.
Why is my SVG icon not scaling?
It's almost always a missing viewBox. SVGO removes it by default when it matches the width and height, so set SVGR's icon: true or add viewBox="0 0 24 24" back.
What size should an SVG icon be in React?
Draw on a 24px grid and render at sizes from your scale, commonly 16, 20 and 24px in interfaces. The icon sizes guide explains when each fits.