Using SVG icons in React without a library
Use SVG logo files in React with plain img tags or hand-made components, add size and color props, and keep gradient IDs unique with useId.
5 min read
You have a folder of SVG files (token logos, chain marks, a few UI icons) and a React app. You don't need an icon package to use them. React can render SVG files as images or as real elements, and each approach takes only a few lines.
The choice comes down to one question: does your code need to reach inside the icon? If not, use an image. If it does, because you want to change its color, animate a part or label it, turn it into a component.
Files as images
The lightest option treats each SVG as a static file and points an <img> at it.
type TokenLogoProps = {
symbol: string
name: string
size?: number
decorative?: boolean
}
export function TokenLogo({ symbol, name, size = 24, decorative = false }: TokenLogoProps) {
return (
<img
src={`/icons/tokens/${symbol}.svg`}
width={size}
height={size}
alt={decorative ? '' : name}
loading="lazy"
decoding="async"
/>
)
}
Put the files in your framework's static folder (often public/) and they are served as is, cached by the browser, and shared across every place the same URL appears. This scales well to a token list with hundreds of rows. Multi-color brand logos, which you shouldn't recolor anyway, belong here.
You can also import a file from your source tree, and the bundler copies it into the build with a hashed name:
import ethLogo from './eth.svg'
What ethLogo contains depends on your bundler. Vite gives you a URL string. Next.js gives you an object whose src field holds the URL, along with the image's width and height. Other setups may need configuration, so check how yours handles .svg before relying on it.
Pass decorative when the logo sits next to visible text that already names the token, so screen readers don't announce "USDC USDC".
Writing an SVG as a component
When you need to style the inside of an icon, paste its markup into a component. React renders SVG elements natively, but attribute names follow JSX rules:
| In the SVG file | In JSX |
|---|---|
class |
className |
fill-rule, clip-rule |
fillRule, clipRule |
clip-path |
clipPath |
stroke-width, stroke-linecap |
strokeWidth, strokeLinecap |
stop-color |
stopColor |
xlink:href |
href |
style="opacity:.5" |
style={{ opacity: 0.5 }} |
aria-* and data-* attributes keep their dashes. React warns in development about names it doesn't recognize, so the console will point you to any you missed.
A mono icon with sensible props looks like this:
import type { SVGProps } from 'react'
type IconProps = Omit<SVGProps<SVGSVGElement>, 'width' | 'height'> & {
size?: number
title?: string
}
export function EthereumMono({ size = 24, title, ...rest }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
role={title ? 'img' : undefined}
aria-label={title}
aria-hidden={title ? undefined : true}
focusable="false"
{...rest}
>
<path d="M12 3 6.4 12.2 12 15.4l5.6-3.2zm0 13.5-5.6-3.3L12 21l5.6-7.8z" />
</svg>
)
}
A few choices here are worth copying:
sizesets both dimensions. Icons are square, and one prop prevents stretched logos.fill="currentColor"replaces the hard-coded color. The icon now takes the CSScolorof its parent, so<EthereumMono className="text-muted" />orstyle={{ color: '#9aa0aa' }}recolors it, and hover or theme changes need no extra code. The currentColor guide covers this in depth.titledecides the accessible role. With a title, the icon is an image with a name. Without one, it is hidden from assistive technology, which is right when text next to it says the same thing. The accessible SVG markup guide explains the trade-offs betweenaria-labeland a<title>element....restgoes last. Callers can addclassName,onClickor adata-testidwithout the component listing them.
Only mono icons should get a color prop. A branded logo keeps its own colors; give it size and title and stop there.
Generating components from files
Converting files by hand works for a dozen icons. For a few hundred, you want a build step. SVGR-style transforms do it automatically: they parse each SVG, optionally optimize it, rename attributes to their JSX forms, can swap a chosen color for currentColor, and wrap the result in a component that spreads extra props onto the root <svg>. Many projects run such a tool through a bundler plugin so that importing a .svg file returns a component instead of a URL.
Whichever tool you use, read a few generated components before trusting the output:
- Is the
viewBoxstill there? Some optimizer settings remove it, and the icon then stops scaling. - Did a color replacement hit the right fills? A blanket "replace every fill with
currentColor" flattens a two-color logo into a silhouette. - Are the
idvalues unique across components? The next section explains why that matters.
Unique IDs with useId
Logos with gradients or clip paths define them with an id and refer to them with url(#id). Inline SVG shares the page's ID namespace, so when a component renders twice, both copies define the same ID. Every reference then resolves to the first one in the document. Hide that first copy, for example in a collapsed dropdown, and the gradient can vanish from the visible one too. The SVG ID collisions guide walks through the failure in detail.
React's useId hook, available since React 18, returns an ID that is unique to each rendered instance and stable between server and client rendering:
import { useId } from 'react'
export function CoinBadge({ size = 32 }: { size?: number }) {
const id = useId()
const fillId = `${id}-fill`
const clipId = `${id}-clip`
return (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
<defs>
<linearGradient id={fillId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8a8f98" />
<stop offset="1" stopColor="#3a3f48" />
</linearGradient>
<clipPath id={clipId}>
<circle cx="16" cy="16" r="16" />
</clipPath>
</defs>
<g clipPath={`url(#${clipId})`}>
<rect width="32" height="32" fill={`url(#${fillId})`} />
</g>
</svg>
)
}
Use one useId call per component and derive every ID from it with a suffix. Then update every reference: fill, stroke, clipPath, mask, filter and any href that points to a #fragment. Missing one reference is the usual reason a gradient still disappears.
Don't generate IDs with Math.random() during render. The server and the client produce different values, React reports a hydration mismatch, and the references break.
What about raw SVG strings
Sometimes you have SVG as a string, from an API or a CMS. React can insert it with dangerouslySetInnerHTML, and the name is an honest warning. Inline SVG is live markup. Browsers don't run <script> elements inserted this way, but event handler attributes such as onload inside the string can still execute. Only do this with SVG you control or have sanitized, and remember that IDs inside the string can still collide. For untrusted logos, such as token images from third-party lists, an <img> tag is the safer default: SVG loaded as an image cannot run scripts.
Picking an approach
Use <img> for full-color logos and anything that appears many times. Use a hand-written or generated component for mono icons that follow text color, icons with interactive parts, and anything that needs an accessible name built from props. Use useId in any component whose SVG contains an id. That covers nearly every icon situation in a React app without adding a dependency.