Icon

Box props on an icon somebody else drew. One adapter for lucide, Tabler, react-icons and the <svg> a designer sent you — sized on the same scale as everything else, themed, and named or hidden on purpose.
Icons
JSX
import { Sun } from 'lucide-react';

<Icon size={7} color="amber-500" hover={{ color: 'amber-300' }}>
  <Sun />
</Icon>

An icon set has exactly one styling channel

An icon component is somebody else's. There is no 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.
That is why one component is enough for every set. <Icon> knows no icon library's API — it knows CSS.
Size
size={4}
size={6}
size={10}
size={16}
JSX
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>

The size is a number of fours, and it is CSS

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.
Careful when porting: an icon set's own size prop counts in pixels. <Sun size={20} /> becomes <Icon size={5}>, not size={20}.
Nothing is passed down as a prop. The size lands in the class, and a CSS declaration outranks the 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.
States
hover it — the stroke thickens because strokeWidth is a prop, not a value handed to lucide once
JSX
import { 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>
Naming
Download
JSX
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>

Hidden by default, named on purpose

An icon nobody named is decoration, and a screen reader should walk straight past it — which is what 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.
This is the same rule <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.
Any icon set
JSX
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>

Install a set, import one icon at a time

This library ships no icons and never will — lucide is at 96M downloads a week with 1,500+ icons, and it is what AI codegen already emits. Install it beside react-box and import the icons you use, one named import each, so a bundler ships only those.
Past thirty or forty icons on one page, weigh the alternative: a sprite or an icon font stops the per-icon cost growing. Below that, named imports win and the whole set never reaches the browser.
Install
Terminal
npm install lucide-react

When the icon is not in lucide

Iconify collects more than 300,000 icons from 200-plus open-source sets — Material, Phosphor, Remix, Font Awesome, and the brand marks in Simple Icons that lucide deliberately does not draw. Every one of them reaches <Icon> the same way, because the only thing an icon source has to be is an element that spreads its props onto an <svg>.
What differs between the ways of getting one is when the icon becomes markup, and that decides whether it can render on a server. Three answers, in the order worth trying them:
One icon, no dependency. Copy the SVG from icones.js.org and paste it into an <Icon>. It is markup, so it renders on a server and costs a bundler nothing — the right answer more often than it looks.
A set, at build time. 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.
A name known only at runtime. @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.
Build time: install
Terminal
npm install -D unplugin-icons @iconify-json/simple-icons @svgr/core @svgr/plugin-jsx
Build time: the plugin and the types
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';
The two @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.
The marks below are that recipe, running: this site is a Vite app, and each of them was compiled into a component by the plugin and handed to <Icon>, which knows nothing about where it came from. The GitHub mark in the home page's call to action is the same import.
Build time: any set, through the same component
JSX
<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>

The runtime bridge, and what it costs

@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.
Reach for it when the name is data. When the name is in your source, the build-time recipe gives you the same icon with no network, no client boundary, and no icon missing from the HTML.
Runtime: a name the build never saw
JSX
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>
  );
}

Which bundlers run the build-time recipe

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.

Not for SVG you draw yourself

<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.
The line is simply who drew it. Somebody else's <svg> — including one pasted in from a designer — goes in an <Icon>. One you are writing out of shapes is a <Svg>.
Draw it instead
JSX
<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>

The hook underneath: useClassNames

<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.
useClassNames
JSX
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>
    </>
  );
}

The props

Everything else is a Box prop — every colour, pseudo-class, breakpoint and theme works here exactly as it does on a <div>.
PropTypeWhat it does
childrenReactElementThe icon: exactly one element, whose <svg> is being styled.
sizenumber | stringWidth and height at once, on the ÷4 scale. Defaults to 6 — 24px.
labelstringNames the icon: role="img" and this text. Without one it is aria-hidden.
propsSVG attributesAttributes forwarded to the icon’s element, over what the icon writes itself.
classNameClassNameTypeMerged with the engine’s classes and the icon’s own.
refRef<SVGSVGElement>Forwarded to the icon’s element.