For the complete documentation index, see 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.

Icon

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

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

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

$ pnpm dlx shadcn@latest add https://lab.pratikthapw.dev/r/icon.json

Usage

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

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

Semantic tokens

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

One icon language across the product.

Product code imports branded tokens. The registry and renderer own the provider, sizing, accessibility, and future migrations.

Home
Bell
Settings
Arrow
import { Button } from "@/components/ui/button";
import {
  ArrowRightIcon,
  BellIcon,
  HomeIcon,
  SettingsIcon,
} from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";
import { cn } from "@/lib/utils";

const previewIcons = [
  { icon: HomeIcon, label: "Home" },
  { icon: BellIcon, label: "Bell" },
  { icon: SettingsIcon, label: "Settings" },
  { icon: ArrowRightIcon, label: "Arrow" },
] as const;

export const IconDemo = ({ className }: { className?: string }) => (
  <div
    className={cn(
      "border-border bg-card text-card-foreground overflow-hidden rounded-xl border",
      className
    )}
  >
    <div className="grid min-h-80 md:grid-cols-[1fr_1.15fr]">
      <div className="border-border flex flex-col justify-between gap-8 border-b p-6 text-left md:border-r md:border-b-0 md:p-8">
        <div className="flex flex-col gap-3">
          <h2 className="max-w-sm text-2xl font-semibold tracking-tight">
            One icon language across the product.
          </h2>
          <p className="text-muted-foreground max-w-md text-sm leading-6">
            Product code imports branded tokens. The registry and renderer own
            the provider, sizing, accessibility, and future migrations.
          </p>
        </div>

        <Button className="self-start" variant="outline">
          Continue
          <RenderIcon data-icon="inline-end" icon={ArrowRightIcon} />
        </Button>
      </div>

      <div className="bg-muted/30 grid grid-cols-2 gap-px p-px">
        {previewIcons.map(({ icon, label }) => (
          <div
            className="bg-background flex min-h-36 flex-col items-center justify-center gap-3 p-5"
            key={label}
          >
            <RenderIcon icon={icon} size={28} />
            <span className="text-muted-foreground text-xs font-medium">
              {label}
            </span>
          </div>
        ))}
      </div>
    </div>
  </div>
);

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.

Lucide

Home
Bell
Check
Arrow right

Hugeicons

Home
Bell
Check
Arrow right

Phosphor

Home
Bell
Check
Arrow right

Radix Icons

Home
Bell
Check
Arrow right

Tabler

Home
Bell
Check
Arrow right
"use client";

import {
  HugeiconsArrowRightIcon,
  HugeiconsBellIcon,
  HugeiconsCheckIcon,
  HugeiconsHomeIcon,
  LucideArrowRightIcon,
  LucideBellIcon,
  LucideCheckIcon,
  LucideHomeIcon,
  PhosphorArrowRightIcon,
  PhosphorBellIcon,
  PhosphorCheckIcon,
  PhosphorHomeIcon,
  RadixArrowRightIcon,
  RadixBellIcon,
  RadixCheckIcon,
  RadixHomeIcon,
  TablerArrowRightIcon,
  TablerBellIcon,
  TablerCheckIcon,
  TablerHomeIcon,
} from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

const catalogs = {
  hugeicons: {
    arrowRight: HugeiconsArrowRightIcon,
    bell: HugeiconsBellIcon,
    check: HugeiconsCheckIcon,
    home: HugeiconsHomeIcon,
  },
  lucide: {
    arrowRight: LucideArrowRightIcon,
    bell: LucideBellIcon,
    check: LucideCheckIcon,
    home: LucideHomeIcon,
  },
  phosphor: {
    arrowRight: PhosphorArrowRightIcon,
    bell: PhosphorBellIcon,
    check: PhosphorCheckIcon,
    home: PhosphorHomeIcon,
  },
  radix: {
    arrowRight: RadixArrowRightIcon,
    bell: RadixBellIcon,
    check: RadixCheckIcon,
    home: RadixHomeIcon,
  },
  tabler: {
    arrowRight: TablerArrowRightIcon,
    bell: TablerBellIcon,
    check: TablerCheckIcon,
    home: TablerHomeIcon,
  },
} as const;

const providerNames = [
  "lucide",
  "hugeicons",
  "phosphor",
  "radix",
  "tabler",
] as const;
const iconNames = ["home", "bell", "check", "arrowRight"] as const;
const sizes = [16, 20, 24, 32] as const;
const strokeWidths = [1, 2, 3] as const;

const providerLabel = {
  hugeicons: "Hugeicons",
  lucide: "Lucide",
  phosphor: "Phosphor",
  radix: "Radix Icons",
  tabler: "Tabler",
} as const;

const iconLabel = {
  arrowRight: "Arrow right",
  bell: "Bell",
  check: "Check",
  home: "Home",
} as const;

type ProviderName = (typeof providerNames)[number];

const ProviderCatalog = ({ provider }: { provider: ProviderName }) => (
  <div className="border-border bg-card rounded-xl border p-5">
    <p className="text-sm font-semibold">{providerLabel[provider]}</p>
    <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">
      {iconNames.map((name) => (
        <div
          className="bg-muted/50 flex min-h-24 flex-col items-center justify-center gap-2 rounded-lg p-3"
          key={name}
        >
          <RenderIcon icon={catalogs[provider][name]} size={24} />
          <span className="text-muted-foreground text-xs">
            {iconLabel[name]}
          </span>
        </div>
      ))}
    </div>
  </div>
);

const ProviderDetails = ({ provider }: { provider: ProviderName }) => (
  <div className="grid gap-4 lg:grid-cols-3">
    <div className="border-border bg-card rounded-xl border p-5 lg:col-span-3">
      <p className="text-sm font-semibold">Catalog</p>
      <div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
        {iconNames.map((name) => (
          <div
            className="bg-muted/50 flex min-h-28 flex-col items-center justify-center gap-3 rounded-lg p-4"
            key={name}
          >
            <RenderIcon icon={catalogs[provider][name]} size={28} />
            <span className="text-muted-foreground text-xs">
              {iconLabel[name]}
            </span>
          </div>
        ))}
      </div>
    </div>

    <div className="border-border bg-card rounded-xl border p-5">
      <p className="text-sm font-semibold">Size</p>
      <div className="mt-5 flex min-h-24 items-end justify-around gap-4">
        {sizes.map((size) => (
          <div className="flex flex-col items-center gap-2" key={size}>
            <RenderIcon icon={catalogs[provider].bell} size={size} />
            <span className="text-muted-foreground text-xs">{size}</span>
          </div>
        ))}
      </div>
    </div>

    <div className="border-border bg-card rounded-xl border p-5">
      <p className="text-sm font-semibold">Stroke</p>
      <div className="mt-5 flex min-h-24 items-end justify-around gap-4">
        {strokeWidths.map((strokeWidth) => (
          <div className="flex flex-col items-center gap-2" key={strokeWidth}>
            <RenderIcon
              icon={catalogs[provider].bell}
              size={28}
              strokeWidth={strokeWidth}
            />
            <span className="text-muted-foreground text-xs">
              {strokeWidth}px
            </span>
          </div>
        ))}
      </div>
    </div>

    <div className="border-border bg-card rounded-xl border p-5">
      <p className="text-sm font-semibold">Stroke and fill</p>
      <div className="mt-5 flex min-h-24 items-end justify-around gap-4">
        <div className="flex flex-col items-center gap-2">
          <RenderIcon icon={catalogs[provider].bell} size={28} />
          <span className="text-muted-foreground text-xs">Stroke</span>
        </div>
        <div className="flex flex-col items-center gap-2">
          <RenderIcon fill icon={catalogs[provider].bell} size={28} />
          <span className="text-muted-foreground text-xs">Filled</span>
        </div>
      </div>
    </div>
  </div>
);

export const IconProvidersExample = () => (
  <Tabs defaultValue="all" className="w-full">
    <TabsList className="max-w-full justify-start overflow-x-auto">
      <TabsTrigger value="all">All</TabsTrigger>
      {providerNames.map((provider) => (
        <TabsTrigger key={provider} value={provider}>
          {providerLabel[provider]}
        </TabsTrigger>
      ))}
    </TabsList>

    <TabsContent value="all" className="grid gap-4 md:grid-cols-2">
      {providerNames.map((provider) => (
        <ProviderCatalog key={provider} provider={provider} />
      ))}
    </TabsContent>

    {providerNames.map((provider) => (
      <TabsContent key={provider} value={provider}>
        <ProviderDetails provider={provider} />
      </TabsContent>
    ))}
  </Tabs>
);

Provider capabilities:

ProviderstrokeWidthfillCaveats
LucideFullYesDefault provider for semantic tokens.
TablerFullYesNone
HugeiconsFullYesRequires both @hugeicons/react and @hugeicons/core-free-icons.
PhosphorQuantizedVia weightstrokeWidth maps to the weight enum: ≤1 thin, ≤2 light, ≤3 regular, else bold. fill selects the fill weight.
RadixScaledNoStroke is scaled ×0.4 to visually match the others; fills are not supported.
SVG assetsIgnoredIgnoredRenders inside an <image> at its intrinsic viewBox, scaled by size.

Size

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

14
18
20
24
32
import { HomeIcon } from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";

const sizes = [14, 18, 20, 24, 32] as const;

export const IconSizeExample = () => (
  <div className="border-border bg-card flex flex-wrap items-end gap-8 rounded-xl border p-6">
    {sizes.map((size) => (
      <div className="flex flex-col items-center gap-2" key={size}>
        <RenderIcon icon={HomeIcon} size={size} />
        <span className="text-muted-foreground text-xs font-medium">
          {size}
        </span>
      </div>
    ))}
  </div>
);
<RenderIcon icon={HomeIcon} size={14} />
<RenderIcon icon={HomeIcon} size={32} />

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.

1
1.5
2
2.5
import { BellIcon } from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";

const strokeWidths = [1, 1.5, 2, 2.5] as const;

export const IconStrokeExample = () => (
  <div className="border-border bg-card flex flex-wrap items-center gap-8 rounded-xl border p-6">
    {strokeWidths.map((strokeWidth) => (
      <div className="flex flex-col items-center gap-2" key={strokeWidth}>
        <RenderIcon icon={BellIcon} size={28} strokeWidth={strokeWidth} />
        <span className="text-muted-foreground text-xs font-medium">
          {strokeWidth}
        </span>
      </div>
    ))}
  </div>
);
<RenderIcon icon={BellIcon} strokeWidth={1} />
<RenderIcon icon={BellIcon} strokeWidth={2.5} />

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.

Arrow
Bell
Check
Home
import {
  ArrowRightIcon,
  BellIcon,
  CheckIcon,
  HomeIcon,
} from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";

const icons = [
  { icon: ArrowRightIcon, label: "Arrow" },
  { icon: BellIcon, label: "Bell" },
  { icon: CheckIcon, label: "Check" },
  { icon: HomeIcon, label: "Home" },
] as const;

export const IconFillExample = () => (
  <div className="border-border bg-card flex flex-wrap items-center gap-8 rounded-xl border p-6">
    {icons.map(({ icon, label }) => (
      <div className="flex flex-col items-center gap-2" key={label}>
        <RenderIcon icon={icon} size={24} />
        <RenderIcon fill icon={icon} size={24} />
        <span className="text-muted-foreground text-xs font-medium">
          {label}
        </span>
      </div>
    ))}
  </div>
);
<RenderIcon icon={BellIcon} />
<RenderIcon fill icon={BellIcon} />

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.

Review the failed checks
import { Button } from "@/components/ui/button";
import { HomeIcon, SettingsIcon, WarningIcon } from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";

export const IconAccessibilityExample = () => (
  <div className="border-border bg-card flex flex-wrap items-center gap-4 rounded-xl border p-6">
    <Button variant="outline">
      <RenderIcon data-icon="inline-start" icon={HomeIcon} />
      Dashboard
    </Button>
    <div className="border-border bg-background flex items-center gap-3 rounded-lg border px-4 py-2 text-sm">
      <RenderIcon icon={WarningIcon} title="Warning" />
      Review the failed checks
    </div>
    <Button aria-label="Open settings" size="icon" variant="secondary">
      <RenderIcon icon={SettingsIcon} />
    </Button>
  </div>
);

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:

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:

import { IconRocket as _TablerRocket } from "@tabler/icons-react";
 
export const TablerRocketIcon = createIconToken(_TablerRocket, "tabler");

SVG asset descriptors are inferred automatically:

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

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.

.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"
      }
    }
  ]
}

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.