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?: ReactNodeprop 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:
sizeversuswidth/height,strokeWidthversusweight, 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
IconTokenvalues fromicons.tsx. The provider is an implementation detail of the registry. - Type-safe. Only tokens created with
createIconTokensatisfyRenderIcon'siconprop, 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-importsturns the boundary from a convention into a failing check in CI.
Ownership stays separated:
icons.tsxowns provider imports, provider selection, and semantic names.render-icon.tsxowns provider adaptation and accessibility defaults.- Product code imports named tokens from
icons.tsxandRenderIcononly. - Provider-specific component props do not leak through the product API.
Installation
$ pnpm dlx shadcn@latest add https://lab.pratikthapw.dev/r/icon.jsonUsage
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.
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
Hugeicons
Phosphor
Radix Icons
Tabler
"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:
| 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 sets width and height in pixels, identically for every provider. It defaults to 20.
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.
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.
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.
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.
{
"$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
strokeWidthquantizes to itsweightenum, 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>, sostrokeWidthandfillare 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.