SVGFlow
Open editor
guides / svg-to-react

SVG to React: the gotchas nobody documents

JSX is not HTML, and SVG in JSX is not SVG. Most of a pasted icon compiles unchanged, which is exactly what makes the parts that don't so annoying to track down.

1. Hyphenated attributes become camelCase

stroke-width is strokeWidth, stop-color is stopColor, class is className. The exceptions are data-* and aria-*, which stay hyphenated.

React ignores attributes it doesn't recognise on SVG elements without warning in production builds, so a missed conversion shows up as a silently unstyled shape rather than an error.

2. style takes an object

A string style throws. It has to become an object with camelCased keys — note the double braces: one for the JSX expression, one for the object literal.

// before
<path style="fill:#2f6df6;stroke-width:2" />

// after
<path style={{ fill: '#2f6df6', strokeWidth: '2' }} />
Try this in the editor
Load the sample icon, select a path, and change its fill — the source panel updates as you go.

3. Always spread props onto the root

An icon component that can't take a className, a width, or an onClick gets copy-pasted into a second component the first time somebody needs one. A single spread on the root <svg> prevents that, and putting it last lets callers override the defaults.

export function Bolt(props) {
  return <svg viewBox="0 0 24 24" fill="currentColor" {...props} ></svg>;
}

The SVG to JSX tool emits exactly this shape.

4. Gradient ids collide

This is the one that produces the genuinely baffling bug. Two icons on the page both define <linearGradient id="a">. Ids are document-global, so the second definition wins for both — and one icon renders with the other's gradient. Unmount the first icon and the second one's fill silently changes.

Fix it by namespacing ids per component instance. useId() gives you a value that is stable across server and client rendering, which a random string is not:

export function Badge(props) {
  const id = useId();
  return (
    <svg viewBox="0 0 24 24" {...props}>
      <linearGradient id={`${id}-g`}></linearGradient>
      <path fill={`url(#${id}-g)`} d="…" />
    </svg>
  );
}

The generated value contains colons — :r0: — which is legal in an id and fine inside url(#…), but will not work if you also try to select it from CSS without escaping. The alternative is hoisting shared gradients into a single sprite defined once at the app root, which is better anyway when several icons share one definition.

5. Syntax that fails outright

The previous four fail quietly. These fail loudly, which makes them easier — worth knowing so you recognise the error rather than the symptom.

  • Every element must close. <path d="…"> is valid SVG and a JSX error; it has to be <path d="…" />. Exporters that emit HTML-style markup trip this on the first line.
  • Comments change form. <!-- … --> is a parse error inside JSX. Use {/* … */}.
  • Namespaced attributes lose the colon. xlink:href becomes xlinkHref, though in any browser you still support you can just use href. xmlns:xlink can usually be deleted.
  • Inline <style> blocks need escaping, because CSS braces read as JSX expressions. Wrap the rules in a template literal inside an expression, or lift them out of the component entirely — which you should do regardless.

6. Give it a size and colour contract

A converted icon is only half a component. The other half is deciding how callers control it, and there is one convention worth defaulting to: make the icon behave like a letter of text.

export function Bolt({ size = '1em', ...props }) {
  return (
    <svg
      width={size} height={size}
      viewBox="0 0 24 24" fill="currentColor"
      aria-hidden="true" focusable="false"
      {...props}
    />
  );
}

Sizing in em rather than pixels means the icon scales with whatever text it sits next to and lines up without a magic vertical-align value. fill="currentColor" gives it the surrounding text colour for free — see changing SVG colours with CSS. The two ARIA attributes make it decorative by default, which is correct for the overwhelming majority of icons; see making icons accessible for when to override that.

7. Typing it

In TypeScript the spread needs a type, and hand-writing one is unnecessary — React ships the exact interface:

import type { SVGProps } from 'react';

type IconProps = SVGProps<SVGSVGElement> & { size?: string | number };

export function Bolt({ size = '1em', ...props }: IconProps) {}

That gives callers autocomplete for every valid SVG attribute and event handler, and catches the camelCase mistakes from section 1 at compile time rather than as a shape that silently renders unstyled. The SVG to JSX tool emits the typed form when you ask for TypeScript output.

8. Shipping more than a handful

A component per icon is the right default, and it stops being right somewhere around fifty. Each icon is JavaScript the bundler parses, the browser downloads and the framework reconciles — for markup that never changes after first paint.

Two things keep the component approach viable longer than people expect. Export each icon from its own module rather than re-exporting everything through one barrel file, so a bundler can drop what no page imports. And check that your build does not mark the package as side-effectful, because that quietly disables the elimination you were counting on.

Past that point, a sprite is the better shape: one file, one request, cached independently of your JavaScript, referenced by fragment.

<svg width="24" height="24" aria-hidden="true">
  <use href="/icons.svg#bolt" />
</svg>

The trade-offs are real and worth stating. You lose per-icon tree-shaking — every visitor downloads the whole sheet. Inherited properties like color and fill still cross into the referenced content, so currentColor keeps working, but CSS selectors do not match inside it, so per-part styling is out. And an external sprite is subject to CORS.

The pragmatic split most teams land on: components for the dozen icons in the app shell, where tree-shaking and props matter, and a sprite for the long tail. The Sprite Generator builds the sheet and emits the <use> markup for each symbol.