DEV Community

SVG to React
SVG to React

Posted on Edited on

Turning a folder of SVG icons into React components (without a build plugin)

You have a folder of SVG icons from a designer, an icon set, or an old project. You want them as React components: <SearchIcon />, <CloseIcon />, each taking a className, a size, maybe a title. The usual answer is "add SVGR to your bundler." That works, but sometimes you can't or don't want to touch the build. Maybe it's a locked-down framework config, a design-system package that ships plain .js, or you just want readable component files in the repo that anyone can open and edit.

This post walks through a plain Node script that converts a directory of .svg files into .jsx components. There's no webpack loader or Vite plugin, and the only runtime dependency is React.

What actually changes between SVG and JSX

An SVG file is XML, and JSX is close to it, but not the same. A converter has to handle these differences:

  1. Hyphenated attributes become camelCase. stroke-width → strokeWidth, fill-rule → fillRule, clip-path → clipPath, stroke-linecap → strokeLinecap.
  2. class becomes className.
  3. Namespaced attributes change. xlink:href → xlinkHref (or just href in modern browsers), xml:space → xmlSpace.
  4. Inline style strings become objects. style="fill:red;opacity:.5" → style={{ fill: "red", opacity: ".5" }}.
  5. Junk gets removed. XML declarations, <!DOCTYPE>, comments and editor metadata (sodipodi:*, inkscape:*) have no place in a component.

data-* and aria-* attributes stay hyphenated in React, so don't camelCase those.

The script

Create scripts/svg-to-components.mjs:

import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";

const SRC = process.argv[2] ?? "icons";
const OUT = process.argv[3] ?? "src/components/icons";

const toPascal = (name) =>
  name
    .replace(/\.svg$/i, "")
    .split(/[^a-zA-Z0-9]+/)
    .filter(Boolean)
    .map((w) => w[0].toUpperCase() + w.slice(1))
    .join("") + "Icon";

const camel = (attr) => attr.replace(/[-:]([a-z])/g, (_, c) => c.toUpperCase());

function styleToObject(css) {
  const pairs = css
    .split(";")
    .map((d) => d.trim())
    .filter(Boolean)
    .map((d) => {
      const [k, ...v] = d.split(":");
      return `${JSON.stringify(camel(k.trim()))}: ${JSON.stringify(v.join(":").trim())}`;
    });
  return `{{ ${pairs.join(", ")} }}`;
}

function convertAttributes(markup) {
  return markup.replace(
    /\s([a-zA-Z_:][\w:.-]*)="([^"]*)"/g,
    (match, name, value) => {
      if (name.startsWith("data-") || name.startsWith("aria-")) return match;
      if (/^(sodipodi|inkscape):/.test(name)) return "";
      if (name === "class") return ` className="${value}"`;
      if (name === "style") return ` style=${styleToObject(value)}`;
      if (name === "xmlns:xlink") return "";
      return ` ${camel(name)}="${value}"`;
    }
  );
}

function clean(svg) {
  return svg
    .replace(/<\?xml[\s\S]*?\?>/g, "")
    .replace(/<!DOCTYPE[\s\S]*?>/gi, "")
    .replace(/<!--[\s\S]*?-->/g, "")
    .replace(/<metadata[\s\S]*?<\/metadata>/gi, "")
    .replace(/<sodipodi:[\s\S]*?\/>/gi, "")
    .trim();
}

function toComponent(name, svg) {
  let jsx = convertAttributes(clean(svg));
  // Drop fixed width/height on the root and spread props instead.
  jsx = jsx.replace(/<svg([^>]*)>/, (_, attrs) => {
    const kept = attrs.replace(/\s(width|height)="[^"]*"/g, "");
    return `<svg${kept} width={size} height={size} {...props}>`;
  });
  return `export default function ${name}({ size = 24, ...props }) {
  return (
    ${jsx}
  );
}
`;
}

await mkdir(OUT, { recursive: true });
const files = (await readdir(SRC)).filter((f) => f.toLowerCase().endsWith(".svg"));
const exports = [];

for (const file of files) {
  const name = toPascal(file);
  const svg = await readFile(path.join(SRC, file), "utf8");
  await writeFile(path.join(OUT, `${name}.jsx`), toComponent(name, svg));
  exports.push(`export { default as ${name} } from "./${name}.jsx";`);
}

await writeFile(path.join(OUT, "index.js"), exports.join("\n") + "\n");
console.log(`Converted ${files.length} icons into ${OUT}`);
Enter fullscreen mode Exit fullscreen mode

Run it:

node scripts/svg-to-components.mjs icons src/components/icons
Enter fullscreen mode Exit fullscreen mode

With an input file icons/arrow-right.svg like this:

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
     fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
  <!-- exported from editor -->
  <path d="M5 12h14M13 6l6 6-6 6"/>
</svg>
Enter fullscreen mode Exit fullscreen mode

you get ArrowRightIcon.jsx:

export default function ArrowRightIcon({ size = 24, ...props }) {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
     fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width={size} height={size} {...props}>
  <path d="M5 12h14M13 6l6 6-6 6"/>
</svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

And use it:

import { ArrowRightIcon } from "./components/icons";

<button className="text-blue-600">
  Next <ArrowRightIcon size={16} aria-hidden="true" />
</button>
Enter fullscreen mode Exit fullscreen mode

Run Prettier over the output folder if you want it tidy.

Things that will bite you

Hard-coded colours. If the icons have fill="#111827" baked in, the components won't follow your text colour. For single-colour icons, swap those values for currentColor before or during conversion. You can add a flag for it:

jsx = jsx.replace(/(fill|stroke)="(?!none)[^"]*"/g, '$1="currentColor"');
Enter fullscreen mode Exit fullscreen mode

Don't do this to multi-colour illustrations, or you'll get a solid blob.

Duplicate IDs. Icons that use <linearGradient id="a"> or <clipPath id="clip0"> will clash when two of them render on the same page, because IDs are global to the document. Either prefix the IDs with the component name in the script, or use React's useId() and rewrite id and url(#...) references at runtime.

Removing viewBox. Some optimizers strip viewBox when width and height are present. Keep it. Without viewBox the icon won't scale when you change size.

Untrusted files. If the SVGs come from users rather than your own repo, sanitize them first. <script>, on* handlers and javascript: URLs can all live inside an SVG.

Regex limits. This script uses regular expressions because icon exports are usually simple and predictable. If you're converting complex illustrations with CDATA blocks or embedded <style>, switch to a real XML parser such as fast-xml-parser or svgson and walk the tree instead.

Accessibility

Decorative icons next to text should be hidden from screen readers. Pass aria-hidden="true", and it will pass straight through thanks to {...props}. For standalone icon buttons, put the label on the button:

<button aria-label="Close dialog">
  <CloseIcon aria-hidden="true" />
</button>
Enter fullscreen mode Exit fullscreen mode

When a script beats a plugin, and when it doesn't

A script makes sense when you want components you can see and edit in the repo, when you're publishing an icon package, or when your build setup is out of your hands. A plugin like SVGR makes more sense when icons change often and you'd rather import .svg files directly.

For one-off icons, or to check how a tricky file converts before you script the whole folder, paste it into the SVG to JSX converter. It runs in the browser, and there's a batch mode that turns a set of files into a ZIP of components.

A small script, a clean folder of components, and no extra build step: for most icon sets, that's all you need.

Top comments (0)