<!-- Source: README.md -->

# @codenhub/icons

Zero-runtime icon system for the web. Write `ic-lucide-heart` in your markup and the icon arrives as a CSS mask — no imports, no components, no icon library in the bundle.

`@codenhub/icons` ships 13 generated icon families totalling over 34,000 icons, each with the license notices its artwork requires, so a project can adopt icons without auditing licenses itself.

> [!WARNING] This package is experimental. Its data schema, exports, and plugin options may change before 1.0. Version 0.2.0 removed the semantic alias map and replaced the `ic-stroke-*` class with the `ic-heart/1.5` modifier; see [Notes](#notes).

## Installation

```sh
pnpm add @codenhub/icons
npm install @codenhub/icons
yarn add @codenhub/icons
bun add @codenhub/icons
```

## Usage

Icons reach a project through CSS. Pick the entry point that matches your pipeline; all three understand the same classes.

### Tailwind CSS v4

One line. Every family is resolvable, and Tailwind emits only the icons your markup used.

```css
@import "tailwindcss";
@import "@codenhub/icons/tw";
```

### Plain CSS, no build step

Import the base rules, then a stylesheet per family. Each family is complete, so nothing has to scan your markup.

```css
@import "@codenhub/icons";
@import "@codenhub/icons/lucide";
```

### Vite or PostCSS

The plugins scan your markup and generate only the rules it needs, which keeps the output small without Tailwind.

```ts
// vite.config.ts
import lucide from "@codenhub/icons/data/lucide";
import viteIcons from "@codenhub/icons/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [viteIcons({ content: ["./src/**/*.{html,ts,tsx}"], defaultPrefix: "lucide", families: [lucide] })],
});
```

```css
@import "@codenhub/icons";
```

### The markup

An icon is an element carrying an icon class — an `<i>`, or `.ic` on any element you own.

```html
<!-- standalone -->
<i class="ic-lucide-heart" aria-hidden="true"></i>

<!-- icon beside a label: a real element inside the control -->
<button class="btn"><i class="ic-lucide-check" aria-hidden="true"></i>Submit</button>

<!-- .ic, for a component that renders its own tag (CSS-based methods) -->
<span class="ic ic-lucide-search" aria-hidden="true"></span>

<!-- stroke width, for stroke-based families -->
<i class="ic-lucide-heart/1.5" aria-hidden="true"></i>

<!-- a size from the axis -->
<i class="ic-lucide-search ic-lg" aria-hidden="true"></i>
```

Size and color follow custom properties: set `--ic-size` and `--ic-color` on the icon or any ancestor. The `ic-xs`–`ic-xl` classes are shorthand that set `--ic-size` to a rem value; the default is `1em`. There is no `::before`, `::after`, or form-control form — for a `background-image` icon, resolve the `url()` with `getIconMaskUrl` / `getIconCssProps`.

## Documentation

- [Documentation overview](docs/index.md): what the package is, installation, and the shortest working setup.
- [Concepts](docs/concepts.md): families, name resolution, reserved names, stroke width, and the markup forms.
- [Delivery methods](docs/delivery/index.md): plain CSS, Tailwind CSS v4, the Vite plugin, and the PostCSS plugin.
- [Frameworks](docs/frameworks/index.md): setup for Astro, Next.js, and SvelteKit.
- [JavaScript API](docs/javascript-api.md): the registry, renderers, and third-party set adapter.
- [Licensing](docs/licensing.md): attribution modes and what each build owes.

## Requirements

| Requirement  | Details                                                                               |
| ------------ | ------------------------------------------------------------------------------------- |
| Node.js      | 22.0.0 or newer, for the build-time plugins and helpers.                              |
| CSS imports  | Consumer tooling must resolve package CSS imports.                                    |
| Browsers     | Must support CSS `mask-image`; icons render through masks tinted with `currentColor`. |
| Tailwind CSS | Version 4 or newer, and only for the `/tw` and `/tailwind` entry points.              |
| Optional     | `vite` >= 5.0.0 for the Vite plugin, `postcss` >= 8.0.0 for the PostCSS plugin.       |

## Notes

- An icon is named `prefix:name`, written `ic-prefix-name` in a class, as in `ic-material-symbols-rounded-home`.
- The package names no default family. An unqualified `ic-heart` resolves only where you said what it means: through `defaultPrefix` in the Vite and PostCSS plugins, through `default:` in the Tailwind plugin, or, in the plugin-free path, through the last family stylesheet you imported that defines it. A family that has no icon by that name does not claim the bare selector, so an earlier family still answers to it.
- Stroke width is a modifier on the icon class, `ic-lucide-heart/1.5`, and applies only to families drawn with strokes. It needs one of the plugins: a plugin-free stylesheet has no way to know which widths you want, so families there render at their authored width.
- An icon is an `<i>` (or `.ic` on any element, in the CSS-based methods). There is no `::before`, `::after`, or form-control form: an icon beside a label is a real `<i>` in the control, and a `background-image` icon comes from `getIconMaskUrl` / `getIconCssProps`.
- `xs`, `sm`, `md`, `lg`, and `xl` are reserved (`ic-xs`–`ic-xl` are the size axis): no family may use one as a prefix or ship an icon by that name.
- Size follows `--ic-size`, default `1em`. The `ic-xs`–`ic-xl` classes each set it to a rem value (`0.75`, `0.875`, `1`, `1.25`, `1.5`) and nothing else, so they compose with a stroke modifier. An inlined `<svg>` from the renderer or `mode: "svg"` carries a `1em` box of its own and honours `--ic-size` through the base rules.
- The `/lucide` and other family stylesheets are large by construction — a whole family, because nothing is narrowing it. `dist/css` totals about 22 MB across all 13. Import the families you use, or use Tailwind or a plugin, which emit only what your markup asked for.
- `mode: "svg"` in the Vite plugin rewrites `<i class="ic-...">` tags into inline SVG and nothing else. A class on another element renders nothing in this mode; the plugin warns when it finds one.
- Icon data is generated from upstream packages and committed; `pnpm generate icons` rebuilds it and CI fails on drift.

## License

This package is licensed under the [Apache-2.0](LICENSE) license.

Its bundled, generated icon artwork comes from third parties, under ISC, MIT, and Apache-2.0 terms. See [NOTICE](NOTICE) for the full list. Each family stylesheet opens with its own notice, and the plugins emit the notices of the families a build actually used, so a consumer carries no separate obligation for them; see the documentation for the `attribution` options that control it. A third-party set adopted through `adoptIconifySet` is not covered — a consumer remains responsible for its license terms and attribution material.

<!-- Source: docs/index.md -->

# Icons through CSS, with no runtime

`@codenhub/icons` turns an icon class such as `ic-lucide-heart` into a CSS mask rule. There is no component layer, no icon library in your bundle, and nothing to import per icon. The package ships 13 generated icon families totalling over 34,000 icons, each carrying the license notices its artwork requires.

This package is experimental: its data schema, exports, and plugin options may change before 1.0.

## Setup

### Installation

```sh
pnpm add @codenhub/icons
```

### Quick start

Icons reach a project through CSS. The shortest path with no build step is the base rules plus one family stylesheet:

```css
@import "@codenhub/icons";
@import "@codenhub/icons/lucide";
```

```html
<i class="ic-lucide-heart" aria-hidden="true"></i> <button class="btn"><i class="ic-lucide-check" aria-hidden="true"></i>Submit</button>
```

An icon is an `<i>` carrying an icon class (or `.ic` on any element, in the CSS-based methods). Size and color follow custom properties: set `--ic-size` and `--ic-color` on the icon or any ancestor, or use the `ic-xs`–`ic-xl` size classes.

Four delivery methods understand the same classes — plain CSS, Tailwind CSS v4, a Vite plugin, and a PostCSS plugin. The plugins scan your markup and emit only the rules you used; the plain-CSS family stylesheets are complete and large by construction. See [Delivery methods](docs/delivery/index.md) to choose one, and [Frameworks](docs/frameworks/index.md) for framework-specific setup.

## Requirements

| Requirement  | Details                                                                               |
| ------------ | ------------------------------------------------------------------------------------- |
| Node.js      | 22.0.0 or newer, for the build-time plugins and helpers.                              |
| CSS imports  | Consumer tooling must resolve package CSS imports.                                    |
| Browsers     | Must support CSS `mask-image`; icons render through masks tinted with `currentColor`. |
| Tailwind CSS | Version 4 or newer, and only for the `/tw` and `/tailwind` entry points.              |
| Optional     | `vite` >= 5.0.0 for the Vite plugin, `postcss` >= 8.0.0 for the PostCSS plugin.       |

## Next steps

- [Concepts](docs/concepts.md): families, name resolution, reserved names, stroke width, and the markup forms.
- [Delivery methods](docs/delivery/index.md): the four ways icons reach your CSS and how to choose.
- [Frameworks](docs/frameworks/index.md): setup for Astro, Next.js, and SvelteKit.
- [JavaScript API](docs/javascript-api.md): the registry, renderers, and third-party set adapter.
- [Licensing](docs/licensing.md): attribution modes and what each build owes.

<!-- Source: docs/concepts.md -->

# How the icon system fits together

Every delivery method reads the same classes and shares the same model. This page describes that model once, independent of which method you use.

## Families

An icon family is a namespace of icons with its own prefix, geometry, and license, such as `lucide` or `material-symbols-rounded-fill`. Families are data, generated from their upstream projects and shipped as modules:

```ts
import lucide from "@codenhub/icons/data/lucide";
import phosphorFill from "@codenhub/icons/data/phosphor-fill";
```

Available families:

| Prefix                                                           | Icons     | Style                   | License    |
| ---------------------------------------------------------------- | --------- | ----------------------- | ---------- |
| `lucide`                                                         | 2034      | outlined, stroke-based  | ISC        |
| `phosphor`                                                       | 1512      | outlined                | MIT        |
| `phosphor-thin`, `phosphor-light`, `phosphor-bold`               | 1512 each | outlined, other weights | MIT        |
| `phosphor-fill`                                                  | 1512      | filled                  | MIT        |
| `phosphor-duotone`                                               | 1512      | duotone                 | MIT        |
| `material-symbols-outlined`, `-rounded`, `-sharp`                | 3899 each | outlined                | Apache-2.0 |
| `material-symbols-outlined-fill`, `-rounded-fill`, `-sharp-fill` | 3899 each | filled                  | Apache-2.0 |

Variants such as filled or duotone are separate families rather than options on one, so resolution stays a prefix and a name.

## Names

An icon is identified by `prefix:name`. A class writes that with a dash, because a class name cannot contain a colon:

```html
<i class="ic-lucide-heart"></i> <i class="ic-material-symbols-rounded-home"></i>
```

The longest matching family prefix wins, so `material-symbols-outlined` is preferred over `material` when both are loaded.

An unqualified name resolves against a default family, and against nothing at all when none is configured. The package names no default of its own and ships no curated map of semantic names: `ic-close` means whatever the family you chose calls `close`, and renders nothing if that family has no such icon. Lucide's is named `x`, so under Lucide you write `ic-x`.

Where the default comes from depends on the delivery method:

| Method        | Default family                       |
| ------------- | ------------------------------------ |
| Vite, PostCSS | The `defaultPrefix` option.          |
| Tailwind      | The `default:` plugin option.        |
| Plain CSS     | The last family stylesheet imported. |

```ts
import { IconRegistry } from "@codenhub/icons";
import lucide from "@codenhub/icons/data/lucide";

const registry = new IconRegistry({ defaultPrefix: "lucide" });
registry.registerFamily(lucide);

registry.resolve("heart"); // lucide:heart
registry.resolve("close"); // undefined -- lucide calls it "x"
```

## Reserved names

`ic-xs` through `ic-xl` are the [size axis](#size), so `xs`, `sm`, `md`, `lg`, and `xl` cannot be family prefixes or icon names. Generation refuses a family that claims one. Stroke width is written on the icon class itself, so it claims no prefix.

## Markup

An icon is an element that carries an icon class. Use an `<i>`:

```html
<i class="ic-lucide-search" aria-hidden="true"></i>

<!-- stroke width, for stroke-based families only -->
<i class="ic-lucide-heart/1.5"></i>

<!-- a size from the axis -->
<i class="ic-lucide-search ic-lg"></i>
```

`<i>` is the recommended element and the only one [inline SVG mode](docs/delivery/vite.md#inline-svg-mode) rewrites. In the CSS-based methods the class also works on any element you add `.ic` to, for a framework component that renders its own tag:

```html
<span class="ic ic-lucide-search" aria-hidden="true"></span>
```

There is no `::before`, `::after`, or form-control form: an icon next to a label is a real `<i>` inside the button or link, which composes with a layout gap and works in every delivery method. For an icon painted as a `background-image` — a custom control, an input affix — resolve the `url()` with [`getIconMaskUrl` or `getIconCssProps`](docs/javascript-api.md) and place it yourself.

Size and color follow CSS custom properties:

```css
.toolbar {
  --ic-size: 1.25rem;
  --ic-color: var(--color-accent);
}
```

## Stroke width

Stroke width is a modifier on the icon class, and only families drawn with strokes answer to it. Of the bundled families, Lucide is the only one; the rest are drawn as filled paths and ignore it.

```html
<i class="ic-lucide-heart/1.5"></i> <i class="ic-lucide-heart/3"></i>
```

The width is baked into the artwork the rule carries, so it cannot be a second class applied on top: `ic-heart` and `ic-heart/1.5` are two icons, not one icon and a switch. One class is one rule, so a project pays for the widths it wrote and no others.

That is also why the modifier needs a build step. It works under Vite, PostCSS, and Tailwind, all of which see your markup; the plugin-free family stylesheets render at the family's authored width, because nothing there can know which widths to prepare.

## Size

The default size is `1em`. Set `--ic-size` on the icon or any ancestor to change it, or add a class from the axis:

| Class   | `--ic-size` |
| ------- | ----------- |
| `ic-xs` | `0.75rem`   |
| `ic-sm` | `0.875rem`  |
| `ic-md` | `1rem`      |
| `ic-lg` | `1.25rem`   |
| `ic-xl` | `1.5rem`    |

```html
<i class="ic-lucide-search ic-lg"></i>
```

Each class does one thing — set `--ic-size` to a rem value — so it composes with a stroke modifier and needs no knowledge of the element it lands on. Unlike stroke width, a size is not baked into the artwork, so the axis is part of the base rules and works in every delivery method: the plugin-free family stylesheets, and [inline SVG mode](docs/delivery/vite.md#inline-svg-mode), where the base rules size the rewritten `<svg>` as long as `@import "@codenhub/icons"` is present. The `.ic` form itself is CSS-based methods only, as above.

The five classes are always emitted, so `xs`, `sm`, `md`, `lg`, and `xl` are [reserved](#reserved-names): a family may not ship an icon or take a prefix by one of those names.

<!-- Source: docs/delivery/index.md -->

# How icons reach your CSS

`@codenhub/icons` produces CSS mask rules. What differs between the delivery methods is only where those per-icon rules come from: a complete stylesheet you import, or a plugin that scans your markup and emits just what you used.

## Entry points

| Entry                      | For                         | What it delivers                                                              |
| -------------------------- | --------------------------- | ----------------------------------------------------------------------------- |
| `@codenhub/icons`          | Every method                | The base `.ic` rules, about 2 KB. Never rewritten by a plugin.                |
| `@codenhub/icons/tw`       | Tailwind CSS v4             | Base rules plus the plugin. Every family resolvable, only used icons emitted. |
| `@codenhub/icons/tailwind` | Tailwind CSS v4, configured | The plugin on its own, so `@plugin` can carry options.                        |
| `@codenhub/icons/<family>` | Plain CSS, no build step    | One complete family, every icon written out.                                  |
| `@codenhub/icons/vite`     | Vite                        | Scans your markup and generates only the rules it needs.                      |
| `@codenhub/icons/postcss`  | PostCSS                     | The same, for a PostCSS pipeline.                                             |

`@import "@codenhub/icons"` means exactly one thing everywhere: the base rules the icon classes build on. No plugin rewrites it, no plugin deletes it. What differs between the methods is only where the per-icon mask rules come from.

## Choosing one

| If your project…                                           | Use                          |
| ---------------------------------------------------------- | ---------------------------- |
| Already uses Tailwind CSS v4                               | [Tailwind](docs/delivery/tailwind.md)      |
| Builds with Vite (or a Vite-based framework)               | [Vite plugin](docs/delivery/vite.md)       |
| Runs a PostCSS pipeline but not Vite (Next.js, others)     | [PostCSS plugin](docs/delivery/postcss.md) |
| Has no build step, or you want the simplest possible setup | [Plain CSS](docs/delivery/plain-css.md)    |

The plugin methods keep output small without any manual curation: a project using three icons ships about 10 KB of CSS. The plain-CSS family stylesheets are complete by construction — Lucide is about 1 MB, all 13 families about 22 MB — so that method trades size for having no build integration at all.

The `ic-lucide-heart/1.5` stroke-width modifier needs one of the plugin methods; plain CSS renders stroke families at their authored width. See [Concepts](docs/concepts.md) for the shared model and [Frameworks](docs/frameworks/index.md) for framework-specific setup.

<!-- Source: docs/delivery/plain-css.md -->

# Plain CSS, no build step

Import the base rules, then a stylesheet per family. Each family stylesheet is complete, so nothing has to scan your markup.

```css
@import "@codenhub/icons";
@import "@codenhub/icons/phosphor-fill";
@import "@codenhub/icons/lucide";
```

Every rule carries two selectors, the qualified `ic-lucide-heart` and the bare `ic-heart`, sharing one copy of the artwork. That is how this method gets a default family without any configuration to hold one: the last family you import wins every bare name it defines. Above, `ic-heart` is Lucide's.

## Cost

The family stylesheets are large by construction — every icon, because nothing is narrowing them. Lucide is about 1 MB, Material Symbols Rounded about 3.4 MB, all 13 families about 22 MB. A project chooses its cost by choosing which families it imports. If that is too much, use a plugin method or Tailwind, which emit only what your markup asked for.

## Limitations

Stroke width does not work here. It is baked into the artwork each rule carries, and a stylesheet with no build step behind it cannot know which widths you want. Stroke families render at their authored width.

## See also

- [Delivery methods](docs/delivery/index.md) — how the methods compare.
- [Concepts](docs/concepts.md) — name resolution and the markup forms.

<!-- Source: docs/delivery/tailwind.md -->

# Tailwind CSS v4

```css
@import "tailwindcss";
@import "@codenhub/icons/tw";
```

Every family the package ships is resolvable, and Tailwind emits only the icons your markup used. Loading all 13 families costs about 130 ms and 100 MB once per build; a project using three icons ends up with about 10 KB of CSS.

## Configuring the plugin

To pass options, declare the plugin yourself instead of importing `/tw`, which already declares it with no options:

```css
@import "tailwindcss";
@import "@codenhub/icons";
@plugin "@codenhub/icons/tailwind" {
  families: lucide, phosphor-fill;
  default: lucide;
  stroke-width: 1.5;
}
```

| Option         | Purpose                                                               |
| -------------- | --------------------------------------------------------------------- |
| `families`     | Family prefixes to resolve against. Defaults to all of them.          |
| `default`      | Family that unqualified names resolve against. There is no default.   |
| `prefix`       | Class prefix. Defaults to `ic`.                                       |
| `stroke-width` | Applied to stroke-based families that carry no modifier of their own. |
| `attribution`  | `auto` or `off`. See [Licensing](docs/licensing.md).                    |

`families` does not make the build cheaper. A Tailwind plugin handler has to be synchronous — utilities registered after an `await` are dropped — so every family is already loaded by the time the options are read. It decides which names resolve, and which notices the output can carry.

## Attribution

`@import "@codenhub/icons/tw"` inlines a static file, so it carries an ordinary `/*! … */` banner naming every bundled family — a superset notice, which is always safe. Declaring the plugin yourself with `@plugin` narrows resolution, and there the notice is a `--ic-attribution-<family>` custom property on `:root` instead, one per family the build actually used. See [Licensing](docs/licensing.md).

## See also

- [Delivery methods](docs/delivery/index.md) — how the methods compare.
- [Concepts](docs/concepts.md) — name resolution and stroke width.

<!-- Source: docs/delivery/vite.md -->

# Vite plugin

```ts
// vite.config.ts
import lucide from "@codenhub/icons/data/lucide";
import viteIcons from "@codenhub/icons/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [viteIcons({ content: ["./src/**/*.{html,ts,tsx}"], defaultPrefix: "lucide", families: [lucide] })],
});
```

```css
@import "@codenhub/icons";
```

| Option          | Purpose                                                                                     |
| --------------- | ------------------------------------------------------------------------------------------- |
| `families`      | Families to resolve against. Nothing is bundled by default.                                 |
| `defaultPrefix` | Family an unqualified name resolves against. There is no default.                           |
| `content`       | Files and globs to scan, beyond what the build transforms.                                  |
| `prefix`        | Class prefix. Defaults to `ic`.                                                             |
| `strokeWidth`   | Default for stroke-based families whose classes carry no modifier.                          |
| `mode`          | `css` serves mask rules; `svg` replaces `<i class="ic-...">` with inline SVG at build time. |
| `attribution`   | `auto`, `file`, or `off`. See [Licensing](docs/licensing.md).                                 |
| `registry`      | A prepared `IconRegistry`, replacing `families`.                                            |

The generated stylesheet is served as `virtual:icons.css` and injected into every HTML entry point. `@import "@codenhub/icons";` is left alone — it resolves through the package's `style` export condition to the base `.ic` rules and means the same thing whether or not a plugin is in the pipeline. Keep it in your stylesheet; the plugin adds the per-icon rules around it rather than in place of it.

## Scanning

`content` takes literal paths and glob patterns, `src/**/*.{html,tsx}` among them, expanded when the scan runs. It is additive: the plugin also scans every source file the build transforms, so `content` is for what the bundler never reaches, and for making the first paint correct in dev.

The transform-time scan covers `.html`, `.js`, `.jsx`, `.ts`, `.tsx`, `.vue`, `.svelte`, and stylesheet files. Markup in a file type outside that set — a `.astro` or `.md` template — is only seen through `content`, so list those globs explicitly. See [Frameworks](docs/frameworks/index.md). This is about the stylesheet only; which files [inline SVG mode](#inline-svg-mode) rewrites is a separate set.

In dev, Vite serves the HTML before it transforms a single module, so a class the plugin has only ever seen inside a module is not yet known when the page is first served. The stylesheet is therefore served as a module rather than inlined, and the plugin invalidates it whenever a transform turns up something new, so the icon arrives without a reload. Listing your sources in `content` avoids the round trip entirely, because those files are read from disk up front.

## Inline SVG mode

`mode: "svg"` replaces `<i class="ic-...">` tags with inline SVG at build time and emits no stylesheet at all. It rewrites those tags and nothing else, so a class left on any other element — or `.ic` on a `<span>` — renders nothing in this mode: there is no stylesheet to carry its mask. The plugin warns at build time when it finds an icon class on an element it will not rewrite, naming the class and the file.

Switching an existing project from CSS mode is not only a config change, because the element that reaches the browser is no longer the one you wrote. Two things the `<i>` had need attention:

- **Size.** The rendered `<svg>` carries a `1em` box as a presentation attribute, so an icon renders at text size with no stylesheet at all. CSS still wins over that: keep `@import "@codenhub/icons"` in a stylesheet and the base rules size the inlined `<svg>` from `--ic-size` exactly as they size an `<i>`, so setting `--ic-size` on an ancestor and the `ic-xs`–`ic-xl` classes both work. Without that import an icon is fixed at `1em`.
- **`hidden`.** `[hidden] { display: none }` is a user-agent rule for HTML elements, and an inlined icon is in the SVG namespace, so the attribute no longer hides it. Script that toggles an icon needs an explicit `[hidden] { display: none }` rule, and a guard written as `instanceof HTMLElement` stops matching — an SVG element is not one.

Rewriting covers `.html`, `.js`, `.jsx`, `.ts`, `.tsx`, `.vue`, `.svelte`, and `.astro`. An `.astro` file is rewritten in both halves, its frontmatter escaped as JavaScript and its template left as markup. `content` plays no part here — it feeds stylesheet generation, which this mode does not do.

The class has to be written out in full, as a literal string, for the plugin to find it: a name assembled at runtime, as in `` `ic-${name}` ``, cannot be seen at build time. In CSS mode such a class simply has no rule generated. In svg mode it is worse, because there is no stylesheet to fall back on and the element renders as nothing. Reach for [one module per icon](#one-module-per-icon) whenever the icon is chosen at runtime; it is the supported form for that, and it is the only one that works in either mode.

## One module per icon

For icons chosen at runtime, import a single icon instead of a family:

```ts
const { svg } = await import("virtual:@codenhub/icons/lucide/heart");
element.innerHTML = svg;
```

The module exports the rendered markup as `svg` and its default export, and the resolved icon as `icon`. Each import is its own module, so the bundler splits and tree-shakes at icon granularity. A name with no prefix — `virtual:@codenhub/icons/close` — resolves like any other unqualified name. An unknown icon fails the build rather than resolving to nothing.

## See also

- [Delivery methods](docs/delivery/index.md) — how the methods compare.
- [Frameworks](docs/frameworks/index.md) — Astro and SvelteKit build on this plugin.
- [JavaScript API](docs/javascript-api.md) — preparing a `registry` by hand.

<!-- Source: docs/delivery/postcss.md -->

# PostCSS plugin

```ts
import lucide from "@codenhub/icons/data/lucide";
import postcssIcons from "@codenhub/icons/postcss";

postcssIcons({
  content: ["./src/**/*.html"],
  families: [lucide],
  defaultPrefix: "lucide",
  injectBase: true,
  attribution: "auto",
});
```

It takes the same `families`, `defaultPrefix`, `prefix`, and `strokeWidth` options as the [Vite plugin](docs/delivery/vite.md). PostCSS has no asset pipeline, so `attribution` accepts `auto` and `off` only, and there is no `mode: "svg"`.

| Option          | Purpose                                                            |
| --------------- | ------------------------------------------------------------------ |
| `content`       | Files and globs to scan for icon classes.                          |
| `families`      | Families to resolve against. Nothing is bundled by default.        |
| `defaultPrefix` | Family an unqualified name resolves against.                       |
| `injectBase`    | Whether to append the base `.ic` rules. Defaults to `true`.        |
| `prefix`        | Class prefix. Defaults to `ic`.                                    |
| `strokeWidth`   | Default for stroke-based families whose classes carry no modifier. |
| `attribution`   | `auto` or `off`. See [Licensing](docs/licensing.md).                 |

## Scanning

Unlike the Vite plugin, this plugin has no view of the bundler's module graph. When it processes a stylesheet it scans that stylesheet's own text and every path in `content`, and nothing else. List globs that cover wherever your icon classes are written — components, templates, HTML — or only classes that literally appear in the CSS will resolve:

```ts
postcssIcons({
  content: ["./app/**/*.{tsx,jsx,mdx}", "./components/**/*.{tsx,jsx}"],
  families: [lucide],
  defaultPrefix: "lucide",
});
```

The plugin runs once per stylesheet it processes, so apply it to a single global stylesheet rather than to every CSS module.

## See also

- [Delivery methods](docs/delivery/index.md) — how the methods compare.
- [Frameworks: Next.js](docs/frameworks/nextjs.md) — the main reason to reach for PostCSS over Vite.

<!-- Source: docs/frameworks/index.md -->

# Framework setup

Every framework consumes `@codenhub/icons` through one of the [delivery methods](docs/delivery/index.md). This section covers the frameworks whose setup has a wrinkle worth spelling out; the rest follow their bundler.

| Framework                           | Recommended method                              | Notes                                                                                           |
| ----------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| [Astro](docs/frameworks/astro.md)                   | Plain CSS or Tailwind v4; Vite plugin with care | `.astro` is not auto-scanned by the Vite plugin — pass `content`.                               |
| [Next.js](docs/frameworks/nextjs.md)                | Tailwind v4 or PostCSS plugin                   | No Vite pipeline, so the Vite plugin does not apply.                                            |
| [SvelteKit](docs/frameworks/sveltekit.md)           | Vite plugin                                     | `.svelte` is auto-scanned; the cleanest plugin story.                                           |
| Vue, SolidStart, Nuxt, Remix (Vite) | Vite plugin                                     | Follow [SvelteKit](docs/frameworks/sveltekit.md); `.vue` is auto-scanned like `.svelte`.                        |
| Any framework                       | Plain CSS                                       | [Plain-CSS](docs/delivery/plain-css.md) family imports work everywhere that resolves package CSS. |

If your framework is not listed and it builds with Vite, follow the [SvelteKit](docs/frameworks/sveltekit.md) guide. If it builds with webpack or another PostCSS host, follow [Next.js](docs/frameworks/nextjs.md). If in doubt, [plain CSS](docs/delivery/plain-css.md) works anywhere at the cost of larger stylesheets.

<!-- Source: docs/frameworks/astro.md -->

# Astro

Astro builds on Vite, so all four [delivery methods](docs/delivery/index.md) are available. Two of them need no Astro-specific setup.

## Plain CSS (no configuration)

Import the base rules and a family stylesheet from a layout or a global stylesheet:

```css
/* src/styles/icons.css */
@import "@codenhub/icons";
@import "@codenhub/icons/lucide";
```

```astro
---
import "../styles/icons.css";
---
<i class="ic-lucide-heart" aria-hidden="true"></i>
```

Astro resolves the package's CSS exports directly. This is the least-effort path; its cost is stylesheet size and no stroke-width modifier. See [Plain CSS](docs/delivery/plain-css.md).

## Tailwind CSS v4 (no configuration)

With `@tailwindcss/vite` configured, add one import to your Tailwind entry:

```css
@import "tailwindcss";
@import "@codenhub/icons/tw";
```

Only the icons your markup uses are emitted. See [Tailwind CSS](docs/delivery/tailwind.md).

## Vite plugin

Add the plugin in `astro.config.mjs`:

```js
import lucide from "@codenhub/icons/data/lucide";
import icons from "@codenhub/icons/vite";
import { defineConfig } from "astro/config";

export default defineConfig({
  vite: {
    plugins: [
      icons({
        families: [lucide],
        defaultPrefix: "lucide",
        content: ["./src/**/*.{astro,md,mdx,html}"],
      }),
    ],
  },
});
```

Then import the generated stylesheet once, from a layout every page uses:

```astro
---
import "../styles/global.css";
import "virtual:icons.css";
---
```

Both lines are required, and this one is specific to Astro. Everywhere else the plugin injects the generated stylesheet into the HTML itself, from Vite's `transformIndexHtml` hook. A production `astro build` renders pages through Astro's own pipeline rather than as Vite HTML entries and does not put them through that hook, so the injection never reaches them and this import is what puts the mask rules on the page. Without it the build still succeeds, `@import "@codenhub/icons"` still supplies the base rules, and every icon renders as an empty box.

The `content` entry is required, not optional. The plugin auto-scans the files Vite transforms for `.html`, `.js(x)`, `.ts(x)`, `.vue`, `.svelte`, and stylesheets — `.astro` is not in that set, so classes written in `.astro`, `.md`, or `.mdx` templates are only discovered through `content`. List those globs and keep `@import "@codenhub/icons";` in a stylesheet for the base rules.

Check that every class you write resolves in a family you registered. An icon name the registry cannot find is left without a rule and renders as an empty box. CSS mode reports nothing when that happens — in any host, not only in Astro — so the build stays green and the gap is visible only on the page. Inline SVG mode does warn, naming the class and the file.

## Inline SVG mode

`mode: "svg"` works in Astro and rewrites `<i class="ic-...">` tags in `.astro` files — in the template, in the frontmatter, and in a `<script>` block, each escaped for what it is. An icon tag written inside a _string_ in a template expression is the one place it is not handled, because `{cond && <i class="ic-x" />}` holds markup rather than a string and telling those apart needs a parser; put such a tag in the frontmatter or a component instead. Everything above about delivering the mask stylesheet stops applying: svg mode emits no per-icon rules, so neither the `virtual:icons.css` import nor `content` does anything here, and a class on any element the plugin does not rewrite — anything that is not an `<i>` tag — renders nothing, because there is no stylesheet behind it.

Keep `@import "@codenhub/icons"` even so. An inlined `<svg>` renders at `1em` on its own, but that base stylesheet is what lets `--ic-size` and the `ic-xs`–`ic-xl` size classes reach it. See [Vite plugin § Inline SVG mode](docs/delivery/vite.md#inline-svg-mode).

`.md` and `.mdx` are not rewritten. An icon class written in one of those reaches the page as a bare `<i>` element with no stylesheet behind it, so it renders as nothing at all — not even the empty box CSS mode would leave. Nothing reports it, either: the unresolved-class warning only covers the files this mode rewrites, so such a page builds green and ships blank.

There are two ways through it. In `.mdx`, put the icon in a component and use the component from the markdown: the component is an `.astro`, `.jsx`, or `.tsx` file, all of which are rewritten. In plain `.md`, which cannot import a component, there is no such route — a site that writes icon classes directly into its markdown wants CSS mode, where `content` globs cover `.md` and `.mdx` like any other file.

## See also

- [Vite plugin](docs/delivery/vite.md) — full option reference and the scanning model.
- [Delivery methods](docs/delivery/index.md) — how the methods compare.

<!-- Source: docs/frameworks/nextjs.md -->

# Next.js

Next.js builds with webpack or Turbopack, not Vite, so `@codenhub/icons/vite` does not apply. Three methods do. All of them produce plain CSS, so the App Router, React Server Components, and streaming are unaffected.

## Tailwind CSS v4

If the project uses Tailwind v4 through `@tailwindcss/postcss`, add one import to your global stylesheet:

```css
/* app/globals.css */
@import "tailwindcss";
@import "@codenhub/icons/tw";
```

Only the icons your markup uses are emitted. See [Tailwind CSS](docs/delivery/tailwind.md).

## PostCSS plugin

Add the plugin to `postcss.config.mjs` and give it globs for wherever icon classes are written — it scans the stylesheet text and `content` only, with no view of the module graph:

```js
import lucide from "@codenhub/icons/data/lucide";
import icons from "@codenhub/icons/postcss";

export default {
  plugins: [
    icons({
      families: [lucide],
      defaultPrefix: "lucide",
      content: ["./app/**/*.{tsx,jsx,mdx}", "./components/**/*.{tsx,jsx}"],
    }),
  ],
};
```

Keep `@import "@codenhub/icons";` at the top of `app/globals.css` for the base rules. See [PostCSS plugin](docs/delivery/postcss.md).

## Plain CSS

The simplest option, at the cost of stylesheet size:

```css
/* app/globals.css */
@import "@codenhub/icons";
@import "@codenhub/icons/lucide";
```

See [Plain CSS](docs/delivery/plain-css.md).

## See also

- [Delivery methods](docs/delivery/index.md) — how the methods compare.
- [Concepts](docs/concepts.md) — name resolution and the markup forms.

<!-- Source: docs/frameworks/sveltekit.md -->

# SvelteKit

SvelteKit builds on Vite, and `.svelte` is one of the file types the Vite plugin auto-scans, so this is the cleanest plugin setup of any framework.

## Vite plugin

Add the plugin in `vite.config.ts` alongside `sveltekit()`:

```ts
import { sveltekit } from "@sveltejs/kit/vite";
import lucide from "@codenhub/icons/data/lucide";
import icons from "@codenhub/icons/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit(),
    icons({
      families: [lucide],
      defaultPrefix: "lucide",
      content: ["./src/**/*.{svelte,html}"],
    }),
  ],
});
```

```css
/* src/app.css */
@import "@codenhub/icons";
```

Classes in `.svelte` components are picked up by the transform-time scan with no `content` entry at all. Listing `src/**/*.{svelte,html}` anyway makes the first paint correct in dev, before Vite has transformed those modules. `mode: "svg"` also works on `.svelte` files.

## Other methods

[Tailwind CSS v4](docs/delivery/tailwind.md) and [plain CSS](docs/delivery/plain-css.md) work in SvelteKit exactly as they do anywhere else. Vue, SolidStart, and other Vite-based frameworks follow this same setup — `.vue` is auto-scanned like `.svelte`.

## See also

- [Vite plugin](docs/delivery/vite.md) — full option reference and the scanning model.
- [Delivery methods](docs/delivery/index.md) — how the methods compare.

<!-- Source: docs/javascript-api.md -->

# JavaScript API

The plugins are built on a small set of exported helpers. Reach for them directly when you generate CSS or SVG outside a bundler, or when you need to prepare a registry the plugins can reuse.

```ts
import { generateIconSetCss, getIconCssProps, IconRegistry, renderSvg } from "@codenhub/icons";
import lucide from "@codenhub/icons/data/lucide";

const registry = new IconRegistry({ defaultPrefix: "lucide" });
registry.registerFamily(lucide);

const heart = registry.resolve("lucide:heart");
renderSvg(heart!, { strokeWidth: 1.5 });

const { css, families } = generateIconSetCss(["ic-lucide-heart", "ic-lucide-heart/1.5"], registry);

getIconCssProps("lucide:x", registry);
// { "--ic-uri": 'url("data:image/svg+xml;…")', "--ic-mask": "var(--ic-uri)" }
```

`generateIconSetCss` returns the families it drew from alongside the CSS, which is what lets a caller emit the right license notice.

## A background-image icon

The base rules render an icon as an element (`<i>` or `.ic`) or an inlined `<svg>`, and nothing else. When you need one as a `background-image` — a custom control, an input affix, a `::before` you own — resolve the `url()` yourself:

```ts
getIconMaskUrl("lucide:search", registry); // 'url("data:image/svg+xml;…")'
getIconCssProps("lucide:search", registry); // { "--ic-uri": …, "--ic-mask": "var(--ic-uri)" }
```

`getIconMaskUrl` also takes a string that begins with `<svg` and encodes it directly, skipping the registry — pass the element itself, with no leading whitespace or XML declaration. `getIconCssProps` gives the two custom properties an inline `style` attribute or a CSS-in-JS object needs; pair them with `background-image: var(--ic-uri)` or `mask-image: var(--ic-mask)` and your own sizing.

## Loading a family on demand

```ts
registry.registerLoader("phosphor", () => import("@codenhub/icons/data/phosphor"));

await registry.resolveAsync("phosphor:heart");
```

`resolve` is synchronous and sees only loaded families; `resolveAsync` loads the family first. Build-time consumers use the synchronous path, because a build knows its families before it starts.

## Third-party icon sets

`adoptIconifySet` converts an IconifyJSON-shaped document into this package's internal family contract:

```ts
import { adoptIconifySet } from "@codenhub/icons";

registry.registerFamily(adoptIconifySet(someIconifySet, { attribution: "notice" }));
```

Pass `attribution: "credit"` instead when the adopted set's license requires visible authorship credit, such as CC-BY.

Adopted sets are `extended` tier: the licensing promise this package makes covers the families it generates, not data a consumer supplies. See [Licensing](docs/licensing.md).

<!-- Source: docs/licensing.md -->

# Licensing and attribution

Every bundled, generated family ships the license text and attribution notice its artwork requires; those files travel with the package whatever a build does. A third-party set adopted through `adoptIconifySet` is not covered by that promise — bring your own licensing material for it.

## Attribution modes

What reaches your own output is the `attribution` option:

| Mode             | Behavior                                                                                    |
| ---------------- | ------------------------------------------------------------------------------------------- |
| `auto` (default) | Prepends a `/*! … */` banner to the generated CSS, naming only the families the build used. |
| `file`           | Emits `icons-attribution.txt` as a build asset instead. Vite only.                          |
| `off`            | Emits nothing, and warns when a family used still requires a notice.                        |

## How the notice travels

The vehicle differs by delivery method, because not every method can carry a comment:

| Method             | How the notice travels                                                                   |
| ------------------ | ---------------------------------------------------------------------------------------- |
| Vite               | A `/*! … */` banner on the generated CSS, or `icons-attribution.txt` under `file`.       |
| PostCSS            | A `/*! … */` banner on the generated CSS.                                                |
| Tailwind `/tw`     | A `/*! … */` banner opening `dist/tw/index.css`, naming every bundled family.            |
| Tailwind `@plugin` | A `--ic-attribution-<family>` custom property on `:root`, one per family the build used. |
| Plain CSS          | A `/*! … */` banner opening each family stylesheet you imported.                         |

`@import "@codenhub/icons/tw"` inlines a static file, so it carries an ordinary `/*! … */` banner. That file makes every bundled family resolvable, so the banner names them all — a superset notice, which is always safe. Declaring the plugin yourself with `@plugin "@codenhub/icons/tailwind"` narrows resolution, and there the notice is a `--ic-attribution-<family>` custom property instead: Tailwind's plugin API builds declarations, not comments, and the property is emitted the first time a family produces a utility, so the output carries notices for the artwork it actually shipped and no other.

## Obligation levels

Obligations come in three levels, because permissive is not the same as free of obligation:

- `none` — public-domain dedications. Nothing is owed.
- `notice` — MIT, ISC, Apache-2.0. The notice must travel with distributed output, which `auto` and `file` handle for you.
- `credit` — CC-BY and similar. The author must be credited visibly.

Every bundled family is `core` tier, meaning `none` or `notice`, so a default build satisfies every obligation automatically. A minifier configured with `legalComments: "none"` strips CSS comments, which is what `file` mode exists for.

A set adopted through `adoptIconifySet` is `extended` tier — you remain responsible for its license terms and attribution material. See [JavaScript API](docs/javascript-api.md).

<!-- Source: docs/changelog/0.2.0.md -->

# 0.2.0

## Added

- `@codenhub/icons/tw` for Tailwind v4: the base rules plus a plugin that generates icons as Tailwind matches them, so a project carries only what its markup used and ships no stylesheet. `@codenhub/icons/tailwind` exposes the plugin on its own, so `@plugin` can carry options.
- `@codenhub/icons/<family>` for a plain CSS pipeline: one complete family, every icon written out because nothing is there to narrow it. Each rule carries both the qualified and the bare selector, so import order picks the default family by plain cascade.
- A license banner on `@codenhub/icons/tw`, naming every bundled family. The configured `@plugin` path keeps its per-family `--ic-attribution-<family>` property, which is the most Tailwind's declaration-only plugin API allows.
- An `engines` field requiring Node 22. The scanner calls `fs.globSync`, stable only from Node 22, and the README already stated the requirement — without the field a consumer on Node 20 installed cleanly and then failed at build time with no warning.

## Changed

- **Breaking.** `@import "@codenhub/icons"` is now always the base rules and nothing else. The Vite plugin no longer substitutes the generated set at that line, nor deletes it in svg mode; the masks arrive through `virtual:icons.css` and the injected stylesheet instead. The same line previously meant two different things depending on configuration, and a pipeline without a plugin resolved it to base rules, rendered nothing, and said nothing.
- The Tailwind plugin reads its stroke width option as `stroke-width`. The camelCase `strokeWidth` still works for a JavaScript caller and wins when both are given.

## Fixed

- The `content` option expands glob patterns. It is documented as taking file paths or globs and the README example passes one, but the scanner only ever called `statSync` on each entry, so a pattern matched nothing and content scanning silently did not happen.
- The documented `@plugin` stroke width option reaches the plugin. Tailwind hands an option through exactly as written, but a CSS formatter lowercases a property name, so `strokeWidth` became `strokewidth` and was dropped — icons rendered at their authored width with no error and nothing in the output to explain it.
- The generated stylesheet is referenced as a module in dev rather than inlined as a `<style>` tag. The inlined tag was a snapshot taken before Vite had transformed any module, so a class in a file the plugin had not seen yet was missing from it and nothing could put it back. A build still inlines the tag, where modules are transformed before the HTML is.
