Icons
react-x11 has a real <svg> host, so icons get further than most DOM
libraries — which is exactly why the failures here are unusual. Two renderer
facts shape every entry on this page:
- ntk's
SvgViewignores presentation attributes on the root<svg>element. Inheritance starts from its own defaults, so an icon that putsfill="none" stroke="currentColor" stroke-width="2"on the root renders as solid black blobs — wrong glyphs, not just the wrong colour. Moving those attributes onto an inner<g>fixes it, because<g>does inherit. - Flat style props on
<svg>throw.<svg width={24}>isreact-x11: <svg width=…> is a style property — pass it in style. Size withstyle={{ width: 24 }}; theviewBoxkeeps the aspect ratio.
Fact 2 is why lucide-react, @tabler/icons-react and
@phosphor-icons/react cannot work at all — see the
negative-results register. @heroicons/react
is the exception, and it is documented below.
The iconSource wrapper
Four of the packages below ship whole SVG strings with paint attributes on the root. They all need the same twelve lines, so write it once:
// icon-source.js — re-root an icon's paint attributes onto a <g> so they
// survive SvgView, and pin `color` there so currentColor resolves.
const ROOT = /<svg([^>]*)>([\s\S]*)<\/svg>\s*$/;
export function iconSource(svgText, color = '#000') {
const [, root, body] = ROOT.exec(svgText);
const viewBox = /viewBox="[^"]*"/.exec(root)?.[0] ?? '';
const paint = (
root.match(
/(?:fill|stroke|stroke-width|stroke-linecap|stroke-linejoin|fill-rule|clip-rule)="[^"]*"/g,
) ?? []
).join(' ');
return `<svg xmlns="http://www.w3.org/2000/svg" ${viewBox}><g color="${color}" ${paint}>${body}</g></svg>`;
}
SvgView maps currentColor to the inherited color, which defaults to
#000 — so the color attribute on the <g> is what makes recolouring
work. Note that svgText.replaceAll('currentColor', color) on its own is
not enough: it fixes the colour but not the dropped fill="none", so
stroke icons still paint as filled blobs.
Cache the wrapped strings per (name, style, colour); parsing per mount is avoidable work.
Iconify
Out of the box. @iconify/utils@3.1.4 with any @iconify-json/* set.
Iconify publishes every major icon set as JSON data — @iconify-json/tabler,
@iconify-json/mdi, @iconify-json/ph, @iconify-json/lucide,
@iconify-json/heroicons — plus @iconify/utils, a framework-free toolkit
that looks an icon up and builds SVG markup from it.
This is the highest-leverage route: one pipeline, 200,000+ icons across every
set, no React wrapper to fight. It is also the only string-SVG route that
needs no re-rooting adapter, because iconToSVG returns the icon body
separately from the root attributes, and the paint attributes live inside the
body rather than on the root.
import { getIconData, iconToSVG, iconToHTML, replaceIDs } from '@iconify/utils';
import { icons as tabler } from '@iconify-json/tabler';
function iconSvg(name, color = '#000') {
const data = getIconData(tabler, name); // null if unknown
const built = iconToSVG(data, { height: 24 });
return iconToHTML(replaceIDs(built.body), built.attributes).replaceAll(
'currentColor',
color,
);
}
function Icon({ name, size = 24, color }) {
return <svg source={iconSvg(name, color)} style={{ width: size }} />;
}
// <Icon name="home" color="#7c3aed" />
// <Icon name="settings" size={16} color="#0891b2" />
replaceIDsmatters for icons containing gradients or clip paths; duplicateids across two mounted icons would collide inside one rasterized document. Harmless across separate<svg>nodes, but free to keep.- Pick per-set packages (
@iconify-json/tableris about 5,000 icons in one JSON) rather than the monolithic@iconify/json, which is all 200,000+ and roughly 100 MB. - There are no events inside a rasterized SVG. Wrap it in a
<box onClick>.
@heroicons/react
Out of the box — size it with style. @heroicons/react@2.2.0.
The exception among the *-react icon packages. Heroicons' React components
size themselves with a Tailwind class name rather than flat width/height
attributes, so react-x11's <svg> host accepts them and the path paints. Do
not lump this one in with lucide-react, @tabler/icons-react or
@phosphor-icons/react, all three of which throw.
import { BeakerIcon } from '@heroicons/react/24/solid';
<box style={{ flexGrow: 1 }}>
<BeakerIcon style={{ width: 24, height: 24 }} />
</box>;
The className it sets is ignored by the renderer, so you must pass
style={{ width, height }} yourself — an icon with no size will lay out at
its natural size or collapse, depending on the surrounding flex box.
There is no recolour channel through the component, so if you need a themed
icon use the asset package below, or @iconify-json/heroicons.
lucide
Adapter, ~10 lines. lucide@1.28.0.
Lucide's core package. Alongside the DOM-oriented createIcons, every icon
is exported as plain data: an array of [tag, attrs] pairs. No DOM, no
React, tree-shakeable per icon.
This is the best-fitting icon package for react-x11, because the data shape
skips string parsing entirely — each pair becomes a JSX child of <svg>,
which react-x11 serializes through SvgChildNode (camelCase to kebab
attributes) into ntk's SvgView.
import { createElement } from 'react';
import { House, Sun } from 'lucide';
function Icon({ icon, size = 24, color = '#000', strokeWidth = 2 }) {
return (
<svg viewBox="0 0 24 24" style={{ width: size, height: size }}>
<g
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
>
{icon.map(([tag, attrs], i) =>
createElement(tag, { key: i, ...attrs }),
)}
</g>
</svg>
);
}
// <Icon icon={House} color="#16a34a" />
// <Icon icon={theme === 'dark' ? Sun : House} color={accent} />
Prop changes on SVG children invalidate and repaint correctly — swapping
icon or color in state re-renders the icon.
- Omitting the
<g>and puttingstroke/fillon the<svg>silently renders black filled blobs. - Function props on SVG children are dropped by the serializer, so there are
no per-child events. Wrap the
<svg>in a<box onClick>for a clickable icon. classNameon a child serializes to aclass-nameattribute — harmless, but do not rely on classes for styling.
lucide-static
Adapter — the iconSource wrapper. lucide-static@1.28.0.
The framework-free build of Lucide: every icon is a named export that is just
an SVG markup string. 24×24 viewBox, stroke-based, stroke="currentColor"
with round caps and joins — all on the root element, which is why it needs
the wrapper.
import * as lucide from 'lucide-static';
import { iconSource } from './icon-source.js';
const Icon = ({ name, size = 24, color = '#000' }) => (
<svg source={iconSource(lucide[name], color)} style={{ width: size }} />
);
// <Icon name="House" color="#e11d48" />
// <Icon name="Sun" size={16} color="#eab308" />
Use the lucide data package above where you can; reach for this one when
you want the markup string itself (asset pipelines, caching a pre-built
sheet).
react-icons
Adapter, ~15 lines. react-icons@5.7.0.
The standard React icon aggregator — Font Awesome, Material, Lucide,
Bootstrap, Simple Icons and about fifty more, tens of thousands of icons
behind one API. Out of the box it throws, because IconBase always sets
height/width (default "1em") as flat props on <svg>.
The adapter sidesteps IconBase entirely. An icon component called with
empty props returns the IconBase element, whose props carry exactly what is
needed: attr (the set's root attributes — viewBox, and for stroke-based
sets the paint attributes) and children (the icon's paths as ready-made
React elements). Re-rooting those attributes onto an inner <g> handles
fact 1 above.
import { FaHeart } from 'react-icons/fa';
import { LuHouse } from 'react-icons/lu';
// Works for both fill sets (fa, md, …) and stroke sets (lu, fi, …).
function RIcon({ icon: I, size = 24, color = '#000' }) {
const { attr = {}, children } = I({}).props; // IconBase element
const { viewBox = '0 0 24 24', ...paint } = attr;
return (
<svg viewBox={viewBox} style={{ width: size, height: size }}>
<g
color={color}
fill="currentColor"
stroke="currentColor"
strokeWidth={0}
{...paint}
>
{children}
</g>
</svg>
);
}
// <RIcon icon={FaHeart} color="#e11d48" /> fill-based
// <RIcon icon={LuHouse} color="#2563eb" /> stroke-based
I({})inside render leans on each icon being a plain function component wrappingGenIcon. That has been react-icons' shape since v4, but it is an internal one — if it changes, preferlucideor the Iconify pipeline, which have no such seam.IconContext(global size/colour config) is bypassed; pass props instead.- The
titleprop emits an SVG<title>child, and there is no tooltip or assistive-technology surface behind a rasterized SVG. Omit it. - react-icons is heavy on disk — every set is bundled. If you only need one
style,
lucideor@iconify-json/<set>is a much smaller install.
@tabler/icons
Adapter — the iconSource wrapper. @tabler/icons@3.46.0.
The framework-free Tabler package: 5,000+ MIT icons as SVG files under
icons/outline/ (stroke-based, 24×24) and icons/filled/, with an exports
map that resolves file paths directly. It also ships
tabler-nodes-outline.json/-filled.json — per-icon node arrays, the same
idea as lucide's data exports.
import fs from 'node:fs';
import { iconSource } from './icon-source.js';
const tabler = (name, style = 'outline') =>
fs.readFileSync(
new URL(import.meta.resolve(`@tabler/icons/${style}/${name}.svg`)),
'utf8',
);
const Icon = ({ name, style, size = 24, color = '#000' }) => (
<svg
source={iconSource(tabler(name, style), color)}
style={{ width: size }}
/>
);
// <Icon name="home" color="#dc2626" />
// <Icon name="settings" style="filled" size={16} />
Honestly, though: prefer @iconify-json/tabler + @iconify/utils, which
serves identical art with no adapter at all. This package earns its place
when you want the files themselves (design pipelines, asset preprocessing) or
the JSON node arrays.
- Outline icons start with a
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>bounds rect; it inherits nothing and paints nothing. Harmless. @tabler/icons-reactthrows. See the register.
@phosphor-icons/core
Adapter — the iconSource wrapper.
@phosphor-icons/core@2.1.1.
Phosphor's framework-free package: raw SVG files under
assets/<weight>/<name>.svg for six weights (thin, light, regular,
bold, fill, duotone), 256×256 viewBox, fill-based.
Because Phosphor is fill-based, the raw file does render legible black
glyphs — the default fill happens to match — so the wrapper is only
mandatory for colour. The duotone weight relies on per-path opacity, which
survives fine either way.
import fs from 'node:fs';
import { iconSource } from './icon-source.js';
const phosphor = (name, weight = 'regular') => {
const file = weight === 'regular' ? name : `${name}-${weight}`;
return fs.readFileSync(
new URL(
import.meta.resolve(`@phosphor-icons/core/assets/${weight}/${file}.svg`),
),
'utf8',
);
};
const Icon = ({ name, weight, size = 24, color = '#000' }) => (
<svg
source={iconSource(phosphor(name, weight), color)}
style={{ width: size }}
/>
);
// <Icon name="house" color="#0ea5e9" />
// <Icon name="house" weight="fill" size={32} />
- Non-
regularweights use suffixed filenames (assets/bold/house-bold.svg); the helper above accounts for it. @iconify-json/phserves the same set with no adapter and no filesystem access. Prefer it unless you want the files.@phosphor-icons/reactthrows. See the register.
heroicons (the asset package)
Adapter — the iconSource wrapper. heroicons@2.2.0.
The Tailwind-house set as plain SVG files: 24/outline/*.svg (stroke-based),
24/solid/, 20/solid/ and 16/solid/. No JS at all — the heroicons npm
package is just the files.
import fs from 'node:fs';
import { iconSource } from './icon-source.js';
const heroicon = (name, style = '24/outline') =>
fs.readFileSync(
new URL(import.meta.resolve(`heroicons/${style}/${name}.svg`)),
'utf8',
);
const Icon = ({ name, style, size = 24, color = '#000' }) => (
<svg
source={iconSource(heroicon(name, style), color)}
style={{ width: size }}
/>
);
// <Icon name="home" color="#f59e0b" />
// <Icon name="home" style="24/solid" color="#0ea5e9" />
Use this rather than @heroicons/react when you need to recolour — the React
components render fine but give you no colour channel. heroicons has no JS
index, so enumerate the directory if you need a picker.
Prefer @iconify-json/heroicons through the Iconify
pipeline: it is the same art, with no adapter and no filesystem access. Reach
for the asset package when you specifically want the files.
feather-icons
Adapter, ~5 lines. feather-icons@4.29.2.
The classic 24×24 stroke set, 287 icons. Feather exposes .contents — the
inner SVG markup, without a root element — which makes the adapter a template
literal instead of a regex: build the root and the <g> yourself and fact 1
never applies.
import feather from 'feather-icons';
function featherSvg(name, color = '#000', strokeWidth = 2) {
return `<svg viewBox="0 0 24 24"><g fill="none" stroke="${color}"
stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"
>${feather.icons[name].contents}</g></svg>`;
}
const Icon = ({ name, size = 24, color, strokeWidth }) => (
<svg source={featherSvg(name, color, strokeWidth)} style={{ width: size }} />
);
// <Icon name="alert-circle" color="#f59e0b" />
- Prefer Lucide. Feather is in maintenance mode upstream — no new icons in years. Lucide is its actively-developed fork with five times the coverage and the same visual language. Reach for Feather only if you are matching an existing design.
- Raw
feather.icons.x.toSvg()into<svg source>renders wrong, becausetoSvg()puts every paint attribute on the root.toSvg({ color })setscoloron the root too, where it is equally ignored. Always go through.contents. feather.replace()(the DOM<i data-feather>swapper) is browser-only.