# Icon

Keep five icon providers behind a typed token and renderer boundary.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Problem [#problem]

Direct imports of icon packages at call sites cause five problems:

* **Provider coupling.** Every component that imports `lucide-react` (or any provider) makes that package a product dependency. Swapping providers means editing every one of those files.
* **No type safety.** An `icon?: ReactNode` prop accepts anything. Nothing in the type system distinguishes an icon from a button, a chart, or a typo.
* **Inconsistent APIs.** Providers disagree on the basics: `size` versus `width`/`height`, `strokeWidth` versus `weight`, fill as a boolean or a string. Every call site re-decides.
* **SVG assets left outside.** Brand marks and custom SVGs don't fit a component library's import shape, so they grow a parallel, untyped convention of their own.
* **Bundle sprawl.** Icons reach product code through ad-hoc re-exports and barrel files, so tree-shaking depends on import hygiene nobody enforces.

The coupling is not hypothetical. When one downstream product swapped Lucide for Phosphor, the migration rewrote two registry files, `icons.tsx` and `render-icon.tsx`, and 12 of 13 consuming files needed zero changes. With direct imports, the same swap would have touched every file that renders an icon.

## Solution [#solution]

One boundary, two files, and a failing check:

* **Provider-agnostic.** Product code imports branded `IconToken` values from `icons.tsx`. The provider is an implementation detail of the registry.
* **Type-safe.** Only tokens created with `createIconToken` satisfy `RenderIcon`'s `icon` prop, so an icon reference either compiles or it doesn't.
* **Uniform API.** One contract for every provider: `icon`, `size`, `strokeWidth`, `fill`, `title`. The renderer translates; call sites never do.
* **Explicit registration.** Every icon is registered by name before use, so the bundle only ever contains icons you registered.
* **Lint-enforced.** `no-restricted-imports` turns the boundary from a convention into a failing check in CI.

Ownership stays separated:

* `icons.tsx` owns provider imports, provider selection, and semantic names.
* `render-icon.tsx` owns provider adaptation and accessibility defaults.
* Product code imports named tokens from `icons.tsx` and `RenderIcon` only.
* Provider-specific component props do not leak through the product API.

## Installation [#installation]

<CodeTabs>
  <TabsList>
    <TabsTrigger value="cli">
      Command
    </TabsTrigger>

    <TabsTrigger value="manual">
      Manual
    </TabsTrigger>
  </TabsList>

  <TabsContent value="cli">
    <CodeBlockCommand __bun__="bunx --bun shadcn@latest add https://lab.pratikthapw.dev/r/icon.json" __npm__="npx shadcn@latest add https://lab.pratikthapw.dev/r/icon.json" __pnpm__="pnpm dlx shadcn@latest add https://lab.pratikthapw.dev/r/icon.json" __yarn__="npx shadcn@latest add https://lab.pratikthapw.dev/r/icon.json" />
  </TabsContent>

  <TabsContent value="manual">
    <Steps className="mb-0 pt-2">
      <Step>
        Install the following dependencies:
      </Step>

      ```bash
      npm install @hugeicons/core-free-icons @hugeicons/react @phosphor-icons/react @radix-ui/react-icons @tabler/icons-react lucide-react clsx tailwind-merge
      ```

      <Step>
        Copy and paste the following code into your project.
      </Step>

      <ComponentSource name="icon" title="components/ui/icons.tsx" />

      <ComponentSource name="render-icon" title="components/ui/render-icon.tsx" />

      <Step>
        Update the import paths to match your project setup.
      </Step>
    </Steps>
  </TabsContent>
</CodeTabs>

## Usage [#usage]

Product components import a semantic token instead of importing an icon provider directly.

```tsx
import { Button } from "@/components/ui/button";
import { ArrowRightIcon } from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";

export function ContinueButton() {
  return (
    <Button>
      Continue
      <RenderIcon data-icon="inline-end" icon={ArrowRightIcon} />
    </Button>
  );
}
```

Semantic tokens such as `ArrowRightIcon` are the product-facing exports. Provider-prefixed tokens such as `TablerBellIcon` exist for provider comparison and stay out of product code.

## Examples [#examples]

### Semantic tokens [#semantic-tokens]

The product layer imports named tokens such as `HomeIcon`, `WarningIcon`, or `ArrowRightIcon`. It does not choose a package at the call site.

<ComponentPreview name="icon">
  <IconDemo />
</ComponentPreview>

### Providers [#providers]

Use **All** to compare every registered provider together. Open an individual provider tab to inspect its full comparison catalog, sizes, stroke weights, and stroke-versus-fill behavior.

The renderer normalizes one common API, but providers retain their own drawing style. A stroke width or filled icon can therefore look different across libraries.

<ComponentPreview name="icon-providers">
  <IconProvidersExample />
</ComponentPreview>

Provider capabilities:

| Provider   | `strokeWidth` | `fill`     | Caveats                                                                                                              |
| ---------- | ------------- | ---------- | -------------------------------------------------------------------------------------------------------------------- |
| Lucide     | Full          | Yes        | Default provider for semantic tokens.                                                                                |
| Tabler     | Full          | Yes        | None                                                                                                                 |
| Hugeicons  | Full          | Yes        | Requires both `@hugeicons/react` and `@hugeicons/core-free-icons`.                                                   |
| Phosphor   | Quantized     | Via weight | `strokeWidth` maps to the `weight` enum: ≤1 thin, ≤2 light, ≤3 regular, else bold. `fill` selects the `fill` weight. |
| Radix      | Scaled        | No         | Stroke is scaled ×0.4 to visually match the others; fills are not supported.                                         |
| SVG assets | Ignored       | Ignored    | Renders inside an `<image>` at its intrinsic viewBox, scaled by `size`.                                              |

### Size [#size]

`size` sets width and height in pixels, identically for every provider. It defaults to 20.

<ComponentPreview name="icon-size">
  <IconSizeExample />
</ComponentPreview>

```tsx
<RenderIcon icon={HomeIcon} size={14} />
<RenderIcon icon={HomeIcon} size={32} />
```

### Stroke [#stroke]

`strokeWidth` adjusts stroke weight. Providers translate it differently: Phosphor quantizes it to its `weight` enum, Radix scales it by 0.4, and SVG assets ignore it. The capabilities table above lists the details.

<ComponentPreview name="icon-stroke">
  <IconStrokeExample />
</ComponentPreview>

```tsx
<RenderIcon icon={BellIcon} strokeWidth={1} />
<RenderIcon icon={BellIcon} strokeWidth={2.5} />
```

### Fill [#fill]

`fill` is a boolean that swaps an outline icon for its solid counterpart where the provider supports it. Phosphor selects the `fill` weight; Radix ignores the flag.

<ComponentPreview name="icon-fill">
  <IconFillExample />
</ComponentPreview>

```tsx
<RenderIcon icon={BellIcon} />
<RenderIcon fill icon={BellIcon} />
```

### Accessibility [#accessibility]

Decorative icons are hidden from assistive technology. Add `title` when the icon itself carries meaning. Icon-only buttons still receive their accessible name from the button.

<ComponentPreview name="icon-accessibility">
  <IconAccessibilityExample />
</ComponentPreview>

## Add icons [#add-icons]

Provider imports belong in `icons.tsx`. Import the provider component under a private `_underscore` alias, then export a semantic token under the name product code will use:

```tsx
import { RocketIcon as _Rocket } from "lucide-react";

export const RocketIcon = createIconToken(_Rocket);
```

Add provider-prefixed tokens only when you need provider-level comparison or choice:

```tsx
import { IconRocket as _TablerRocket } from "@tabler/icons-react";

export const TablerRocketIcon = createIconToken(_TablerRocket, "tabler");
```

SVG asset descriptors are inferred automatically:

```tsx
export const LogoIcon = createIconToken({
  src: "/logo.svg",
  viewBox: "0 0 32 32",
});
```

## Enforce the boundary [#enforce-the-boundary]

The architecture is strongest when CI rejects direct provider imports outside the registry and renderer. Pick your provider, then your linter, and adjust the two allowed paths if your shadcn aliases target a different directory.

<Tabs defaultValue="lucide">
  <TabsList>
    <TabsTrigger value="lucide">
      Lucide
    </TabsTrigger>

    <TabsTrigger value="tabler">
      Tabler Icons
    </TabsTrigger>

    <TabsTrigger value="hugeicons">
      Hugeicons
    </TabsTrigger>

    <TabsTrigger value="phosphor">
      Phosphor
    </TabsTrigger>

    <TabsTrigger value="radix">
      Radix Icons
    </TabsTrigger>
  </TabsList>

  <TabsContent value="lucide">
    <Tabs defaultValue="oxlint">
      <TabsList>
        <TabsTrigger value="oxlint">
          Oxlint
        </TabsTrigger>

        <TabsTrigger value="eslint">
          ESLint
        </TabsTrigger>

        <TabsTrigger value="biome">
          Biome
        </TabsTrigger>
      </TabsList>

      <TabsContent value="oxlint">
        ```json title=".oxlintrc.json"
        {
          "$schema": "./node_modules/oxlint/configuration_schema.json",
          "rules": {
            "no-restricted-imports": [
              "error",
              {
                "paths": [
                  {
                    "name": "lucide-react",
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ],
                "patterns": [
                  {
                    "group": ["lucide-react/**"],
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ]
              }
            ]
          },
          "overrides": [
            {
              "files": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "rules": {
                "no-restricted-imports": "off"
              }
            }
          ]
        }
        ```
      </TabsContent>

      <TabsContent value="eslint">
        ```js title="eslint.config.mjs"
        const restrictedIconImports = ["lucide-react"].map((name) => ({
          name,
          message: "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
        }));

        export default [
          {
            files: ["**/*.{js,jsx,ts,tsx}"],
            ignores: ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
            rules: {
              "no-restricted-imports": [
                "error",
                {
                  paths: restrictedIconImports,
                  patterns: [
                    {
                      group: ["lucide-react/**"],
                      message:
                        "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
                    },
                  ],
                },
              ],
            },
          },
        ];
        ```
      </TabsContent>

      <TabsContent value="biome">
        ```json title="biome.json"
        {
          "linter": {
            "rules": {
              "style": {
                "noRestrictedImports": {
                  "level": "error",
                  "options": {
                    "paths": {
                      "lucide-react": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                    },
                    "patterns": [
                      {
                        "group": ["lucide-react/**"],
                        "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                      }
                    ]
                  }
                }
              }
            }
          },
          "overrides": [
            {
              "includes": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "linter": {
                "rules": {
                  "style": {
                    "noRestrictedImports": "off"
                  }
                }
              }
            }
          ]
        }
        ```
      </TabsContent>
    </Tabs>
  </TabsContent>

  <TabsContent value="tabler">
    <Tabs defaultValue="oxlint">
      <TabsList>
        <TabsTrigger value="oxlint">
          Oxlint
        </TabsTrigger>

        <TabsTrigger value="eslint">
          ESLint
        </TabsTrigger>

        <TabsTrigger value="biome">
          Biome
        </TabsTrigger>
      </TabsList>

      <TabsContent value="oxlint">
        ```json title=".oxlintrc.json"
        {
          "$schema": "./node_modules/oxlint/configuration_schema.json",
          "rules": {
            "no-restricted-imports": [
              "error",
              {
                "paths": [
                  {
                    "name": "@tabler/icons-react",
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ],
                "patterns": [
                  {
                    "group": ["@tabler/icons-react/**"],
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ]
              }
            ]
          },
          "overrides": [
            {
              "files": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "rules": {
                "no-restricted-imports": "off"
              }
            }
          ]
        }
        ```
      </TabsContent>

      <TabsContent value="eslint">
        ```js title="eslint.config.mjs"
        const restrictedIconImports = ["@tabler/icons-react"].map((name) => ({
          name,
          message: "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
        }));

        export default [
          {
            files: ["**/*.{js,jsx,ts,tsx}"],
            ignores: ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
            rules: {
              "no-restricted-imports": [
                "error",
                {
                  paths: restrictedIconImports,
                  patterns: [
                    {
                      group: ["@tabler/icons-react/**"],
                      message:
                        "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
                    },
                  ],
                },
              ],
            },
          },
        ];
        ```
      </TabsContent>

      <TabsContent value="biome">
        ```json title="biome.json"
        {
          "linter": {
            "rules": {
              "style": {
                "noRestrictedImports": {
                  "level": "error",
                  "options": {
                    "paths": {
                      "@tabler/icons-react": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                    },
                    "patterns": [
                      {
                        "group": ["@tabler/icons-react/**"],
                        "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                      }
                    ]
                  }
                }
              }
            }
          },
          "overrides": [
            {
              "includes": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "linter": {
                "rules": {
                  "style": {
                    "noRestrictedImports": "off"
                  }
                }
              }
            }
          ]
        }
        ```
      </TabsContent>
    </Tabs>
  </TabsContent>

  <TabsContent value="hugeicons">
    <Tabs defaultValue="oxlint">
      <TabsList>
        <TabsTrigger value="oxlint">
          Oxlint
        </TabsTrigger>

        <TabsTrigger value="eslint">
          ESLint
        </TabsTrigger>

        <TabsTrigger value="biome">
          Biome
        </TabsTrigger>
      </TabsList>

      <TabsContent value="oxlint">
        ```json title=".oxlintrc.json"
        {
          "$schema": "./node_modules/oxlint/configuration_schema.json",
          "rules": {
            "no-restricted-imports": [
              "error",
              {
                "paths": [
                  {
                    "name": "@hugeicons/react",
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  },
                  {
                    "name": "@hugeicons/core-free-icons",
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ],
                "patterns": [
                  {
                    "group": ["@hugeicons/react/**", "@hugeicons/core-free-icons/**"],
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ]
              }
            ]
          },
          "overrides": [
            {
              "files": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "rules": {
                "no-restricted-imports": "off"
              }
            }
          ]
        }
        ```
      </TabsContent>

      <TabsContent value="eslint">
        ```js title="eslint.config.mjs"
        const restrictedIconImports = [
          "@hugeicons/react",
          "@hugeicons/core-free-icons",
        ].map((name) => ({
          name,
          message: "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
        }));

        export default [
          {
            files: ["**/*.{js,jsx,ts,tsx}"],
            ignores: ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
            rules: {
              "no-restricted-imports": [
                "error",
                {
                  paths: restrictedIconImports,
                  patterns: [
                    {
                      group: ["@hugeicons/react/**", "@hugeicons/core-free-icons/**"],
                      message:
                        "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
                    },
                  ],
                },
              ],
            },
          },
        ];
        ```
      </TabsContent>

      <TabsContent value="biome">
        ```json title="biome.json"
        {
          "linter": {
            "rules": {
              "style": {
                "noRestrictedImports": {
                  "level": "error",
                  "options": {
                    "paths": {
                      "@hugeicons/react": "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
                      "@hugeicons/core-free-icons": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                    },
                    "patterns": [
                      {
                        "group": [
                          "@hugeicons/react/**",
                          "@hugeicons/core-free-icons/**"
                        ],
                        "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                      }
                    ]
                  }
                }
              }
            }
          },
          "overrides": [
            {
              "includes": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "linter": {
                "rules": {
                  "style": {
                    "noRestrictedImports": "off"
                  }
                }
              }
            }
          ]
        }
        ```
      </TabsContent>
    </Tabs>
  </TabsContent>

  <TabsContent value="phosphor">
    <Tabs defaultValue="oxlint">
      <TabsList>
        <TabsTrigger value="oxlint">
          Oxlint
        </TabsTrigger>

        <TabsTrigger value="eslint">
          ESLint
        </TabsTrigger>

        <TabsTrigger value="biome">
          Biome
        </TabsTrigger>
      </TabsList>

      <TabsContent value="oxlint">
        ```json title=".oxlintrc.json"
        {
          "$schema": "./node_modules/oxlint/configuration_schema.json",
          "rules": {
            "no-restricted-imports": [
              "error",
              {
                "paths": [
                  {
                    "name": "@phosphor-icons/react",
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ],
                "patterns": [
                  {
                    "group": ["@phosphor-icons/react/**"],
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ]
              }
            ]
          },
          "overrides": [
            {
              "files": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "rules": {
                "no-restricted-imports": "off"
              }
            }
          ]
        }
        ```
      </TabsContent>

      <TabsContent value="eslint">
        ```js title="eslint.config.mjs"
        const restrictedIconImports = ["@phosphor-icons/react"].map((name) => ({
          name,
          message: "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
        }));

        export default [
          {
            files: ["**/*.{js,jsx,ts,tsx}"],
            ignores: ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
            rules: {
              "no-restricted-imports": [
                "error",
                {
                  paths: restrictedIconImports,
                  patterns: [
                    {
                      group: ["@phosphor-icons/react/**"],
                      message:
                        "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
                    },
                  ],
                },
              ],
            },
          },
        ];
        ```
      </TabsContent>

      <TabsContent value="biome">
        ```json title="biome.json"
        {
          "linter": {
            "rules": {
              "style": {
                "noRestrictedImports": {
                  "level": "error",
                  "options": {
                    "paths": {
                      "@phosphor-icons/react": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                    },
                    "patterns": [
                      {
                        "group": ["@phosphor-icons/react/**"],
                        "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                      }
                    ]
                  }
                }
              }
            }
          },
          "overrides": [
            {
              "includes": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "linter": {
                "rules": {
                  "style": {
                    "noRestrictedImports": "off"
                  }
                }
              }
            }
          ]
        }
        ```
      </TabsContent>
    </Tabs>
  </TabsContent>

  <TabsContent value="radix">
    <Tabs defaultValue="oxlint">
      <TabsList>
        <TabsTrigger value="oxlint">
          Oxlint
        </TabsTrigger>

        <TabsTrigger value="eslint">
          ESLint
        </TabsTrigger>

        <TabsTrigger value="biome">
          Biome
        </TabsTrigger>
      </TabsList>

      <TabsContent value="oxlint">
        ```json title=".oxlintrc.json"
        {
          "$schema": "./node_modules/oxlint/configuration_schema.json",
          "rules": {
            "no-restricted-imports": [
              "error",
              {
                "paths": [
                  {
                    "name": "@radix-ui/react-icons",
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ],
                "patterns": [
                  {
                    "group": ["@radix-ui/react-icons/**"],
                    "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                  }
                ]
              }
            ]
          },
          "overrides": [
            {
              "files": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "rules": {
                "no-restricted-imports": "off"
              }
            }
          ]
        }
        ```
      </TabsContent>

      <TabsContent value="eslint">
        ```js title="eslint.config.mjs"
        const restrictedIconImports = ["@radix-ui/react-icons"].map((name) => ({
          name,
          message: "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
        }));

        export default [
          {
            files: ["**/*.{js,jsx,ts,tsx}"],
            ignores: ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
            rules: {
              "no-restricted-imports": [
                "error",
                {
                  paths: restrictedIconImports,
                  patterns: [
                    {
                      group: ["@radix-ui/react-icons/**"],
                      message:
                        "Import tokens from `@/components/ui/icons` and RenderIcon instead.",
                    },
                  ],
                },
              ],
            },
          },
        ];
        ```
      </TabsContent>

      <TabsContent value="biome">
        ```json title="biome.json"
        {
          "linter": {
            "rules": {
              "style": {
                "noRestrictedImports": {
                  "level": "error",
                  "options": {
                    "paths": {
                      "@radix-ui/react-icons": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                    },
                    "patterns": [
                      {
                        "group": ["@radix-ui/react-icons/**"],
                        "message": "Import tokens from `@/components/ui/icons` and RenderIcon instead."
                      }
                    ]
                  }
                }
              }
            }
          },
          "overrides": [
            {
              "includes": ["components/ui/icons.tsx", "components/ui/render-icon.tsx"],
              "linter": {
                "rules": {
                  "style": {
                    "noRestrictedImports": "off"
                  }
                }
              }
            }
          ]
        }
        ```
      </TabsContent>
    </Tabs>
  </TabsContent>
</Tabs>

## Tradeoffs [#tradeoffs]

The boundary has accepted costs:

* Phosphor `strokeWidth` quantizes to its `weight` enum, so intermediate values snap to the nearest of thin, light, regular, or bold.
* Radix Icons have no fill variant, and their stroke is scaled ×0.4 to approximate the other providers. The match is close, not exact.
* Hugeicons spans two packages, which the item installs together.
* Every icon must be registered manually; the payoff is that unknown icons fail to compile instead of surfacing at runtime.
* SVG assets render through an embedded `<image>`, so `strokeWidth` and `fill` are ignored and color follows the SVG's own fills.
* Installing the item pulls in all five provider packages. Standardize on one, then prune the registry and the dependencies you dropped.
