import { Sun } from 'lucide-react';
<Icon size={7} color="amber-500" hover={{ color: 'amber-300' }}>
<Sun />
</Icon>tag that renders one, so Box cannot wrap it, and the only thing it reliably accepts is the className it spreads onto its own <svg>. <Icon> fills that channel with a class the engine generated. Everything else follows from that: the icon takes the props every other component here takes, and none of them are handed to the icon as props it would have to understand.<Icon> knows no icon library's API — it knows CSS.size={4}size={6}size={10}size={16}import { Star } from 'lucide-react';
<Flex gap={6} ai="flex-end">
<Icon size={4}><Star /></Icon>{/* 1rem — 16px */}
<Icon size={6}><Star /></Icon>{/* 1.5rem — 24px, the default */}
<Icon size={10}><Star /></Icon>{/* 2.5rem — 40px */}
</Flex>size is one number for both axes on the ÷4 spacing scale the whole library uses — size={5} is 1.25rem, size={6} is the 24px an icon set draws at, and that is the default. A width or height of your own replaces it; a size beats both.size prop counts in pixels. <Sun size={20} /> becomes <Icon size={5}>, not size={20}.width and height presentation attributes the icon writes for itself — so the set keeps rendering exactly what it always did and the class decides. The same is true of strokeWidth: it is an ordinary Box prop, so unlike a passthrough it can change on hover or at a breakpoint.strokeWidth is a prop, not a value handed to lucide onceimport { Heart } from 'lucide-react';
<Icon
size={7}
strokeWidth={1.5}
color="slate-400"
hover={{ color: 'rose-500', strokeWidth: 2.5 }}
theme={{ dark: { color: 'slate-500' } }}
>
<Heart />
</Icon>import { Download, Trash2 } from 'lucide-react';
<Flex gap={8} ai="center">
{/* Decoration: the label beside it already says everything. */}
<Icon size={5}><Download /></Icon>
{/* Carries meaning on its own: name it. */}
<Icon size={5} label="Delete this row"><Trash2 /></Icon>
</Flex>aria-hidden says and what saying nothing leaves ambiguous. So <Icon> hides by default. Give it a label and it becomes role="img" with that name instead. Write a role or an aria-* of your own — on the icon or in props — and the decision stays yours.<Svg> follows, so every <svg> this library puts on a page answers the question the same way. An icon-only button is the case to watch: the button needs the name, and a label on the icon inside it is one way to give it one.import { Compass } from 'lucide-react';
{/* lucide, Tabler, react-icons — anything that spreads its props onto an <svg>. */}
<Icon size={6} color="sky-500"><Compass /></Icon>
{/* The <svg> a designer sent you, pasted in as it came. */}
<Icon size={6} color="sky-500">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx={12} cy={12} r={9} />
<path d="M12 7v5l3 2" />
</svg>
</Icon>npm install lucide-react<Icon> the same way, because the only thing an icon source has to be is an element that spreads its props onto an <svg>.<Icon>. It is markup, so it renders on a server and costs a bundler nothing — the right answer more often than it looks.unplugin-icons compiles ~icons/<set>/<name> into a component while bundling, out of the icon data in an @iconify-json/<set> devDependency. Nothing is fetched at runtime, only the icons you import are compiled, and the result server-renders like any other element.@iconify/react looks its icon up by name in the browser, over the Iconify API — for an icon that arrives from a CMS or from a user's own data, where no import can be written.npm install -D unplugin-icons @iconify-json/simple-icons @svgr/core @svgr/plugin-jsx// vite.config.ts
import react from '@vitejs/plugin-react';
import icons from 'unplugin-icons/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [react(), icons({ compiler: 'jsx', jsx: 'react' })] });
// icons.d.ts — without this the specifier resolves to nothing and every import is a missing module
/// <reference types="unplugin-icons/types/react" />
// anywhere
import SiGithub from '~icons/simple-icons/github';@svgr packages are what turns the icon data into JSX, and only @svgr/core is declared as a peer dependency — leave @svgr/plugin-jsx out and the build fails with a bare Cannot find module that names neither the plugin nor the icon.<Icon>, which knows nothing about where it came from. The GitHub mark in the home page's call to action is the same import.<Flex gap={6} ai="center" flexWrap="wrap">
<Icon
size={7}
label="GitHub"
theme={{ dark: { color: 'slate-100' }, light: { color: 'slate-800' } }}
>
<SiGithub />
</Icon>
<Icon
size={7}
label="React"
theme={{ dark: { color: 'sky-400' }, light: { color: 'sky-600' } }}
>
<SiReact />
</Icon>
<Icon
size={7}
label="TypeScript"
theme={{ dark: { color: 'blue-400' }, light: { color: 'blue-600' } }}
>
<SiTypescript />
</Icon>
<Icon
size={7}
label="Vite"
theme={{ dark: { color: 'violet-400' }, light: { color: 'violet-500' } }}
>
<SiVite />
</Icon>
</Flex>@iconify/react takes the icon's name as a string and fetches it when it renders, which is the only way to draw an icon nobody could import. The price is paid where it always is: the component is a client component, the server sends no icon at all — an empty placeholder — and the class <Icon> generated lands on the <svg> when the icon arrives with it. It still beats the width the icon writes for itself, so the size and the colour are the ones you asked for rather than a flash of something else.import Icon from '@cronocode/react-box/components/icon';
import { Icon as IconifyIcon } from '@iconify/react';
function CategoryIcon({ name }: { name: string }) {
return (
<Icon size={6} color="sky-500" label={name}>
<IconifyIcon icon={`material-symbols:${name}`} />
</Icon>
);
}unplugin-icons ships adapters for Vite, Rollup, webpack, Rspack, esbuild and Nuxt, so most projects add the config above and are done. The exception worth knowing is Next.js 16, which builds with Turbopack: Turbopack runs no unplugin — the webpack() hook in next.config.mjs never fires — and ~icons/simple-icons/github fails to resolve.next build --webpack does work, and the icon lands in the prerendered HTML. If you would rather keep Turbopack, the other two answers both do: paste the SVG for the handful of icons a page needs, or take the runtime bridge for the ones whose names are data. The Next.js example in this repository does both.<Icon> exists because an icon set's component is not a Box. Yours can be: <Svg> and the nineteen elements beside it in components/svg take these props directly, and their attributes live in props the way every component here keeps them. Wrapping one in an <Icon> adds a class it does not need and hands it attributes it will not read.<svg> — including one pasted in from a designer — goes in an <Icon>. One you are writing out of shapes is a <Svg>.<Svg viewBox="0 0 24 24" width="2rem" label="Nine o'clock" fill="none" stroke="sky-500" strokeWidth={2}>
<Circle cx={12} cy={12} r={9} />
<Path d="M12 7v5l3 2" />
</Svg><Icon> is a thin thing over one hook, and the hook is public because icons are not the only elements this library cannot render. A motion.div, a router's NavLink, a third-party chart — all of them take a className and nothing else. useClassNames resolves the same props Box would and hands back the class list to put on them yourself.styles is defined in element mode only, where the CSS travels as hoistable <style> elements rather than going to a stylesheet. In every other mode it is undefined and rendering it costs nothing, so the line below is what to write either way.import { useClassNames } from '@cronocode/react-box';
import { NavLink } from 'react-router-dom';
function ActiveLink({ to, children }: { to: string; children: React.ReactNode }) {
const { className, styles } = useClassNames({ color: 'sky-500', hover: { color: 'sky-300' } });
return (
<>
{styles}
<NavLink to={to} className={className}>
{children}
</NavLink>
</>
);
}<div>.| Prop | Type | What it does |
|---|---|---|
children | ReactElement | The icon: exactly one element, whose <svg> is being styled. |
size | number | string | Width and height at once, on the ÷4 scale. Defaults to 6 — 24px. |
label | string | Names the icon: role="img" and this text. Without one it is aria-hidden. |
props | SVG attributes | Attributes forwarded to the icon’s element, over what the icon writes itself. |
className | ClassNameType | Merged with the engine’s classes and the icon’s own. |
ref | Ref<SVGSVGElement> | Forwarded to the icon’s element. |