# Icon

_Box Kite 2.2.5 · a markdown copy of https://www.box-kite.dev/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**

```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**

```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**

```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](https://lucide.dev) is at 96M downloads a week with 1,500+ icons, and it is what AI codegen already emits. Install it beside Box Kite 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**

```shell
npm install lucide-react
```

## When the icon is not in lucide

[Iconify](https://iconify.design) 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](https://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**

```shell
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 '@box-kite/react/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 '@box-kite/react';
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>`.

| 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. |

## Icon props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on [/box](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children`required | `React.ReactElement` | — | The icon: exactly one element, whose `<svg>` these props are styling. |
| `size` | `BoxStyleProps['width'] & BoxStyleProps['height']` | — | Width and height at once, on the ÷4 scale: `size={6}` is the 24px an icon set defaults to, and is the default here too. It wins over `width`/`height`. Careful when porting — a set's own `size` is in pixels. |
| `label` | `string` | — | Names the icon — `role="img"` and this text. Without one the icon is `aria-hidden`. |
| `props` | `TagPropsType<'svg'>` | — | Attributes forwarded to the icon's element, over anything the icon writes for itself. |
| `style` | `React.CSSProperties` | — | Inline styles forwarded to the icon's element. Box props are the way to style it; this is for a value that has to be computed per render, which no class can hold. |

## Icon accessibility

- An icon with no `label` is `aria-hidden`, which is right for the common case: the icon repeats a label that is already there, and reading it twice is noise.
- A `label` makes it `role="img"` with that text as its name — for an icon that is the only thing saying what a control does.

Swept with axe on every release, in this state: `Icon`. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

