Icon System in React: lucide-react, Heroicons and Custom SVGs
Pick the right icon library for your React app in 2026 — lucide-react, Heroicons, or roll your own SVG system. Here's how each one holds up.
Why Your Icon Strategy Matters More Than You Think
Icons are small. The decisions around them aren't. Pick the wrong approach in 2026 and you're shipping 40 KB of SVG bloat for 12 icons you actually use, or you're stuck maintaining a hand-rolled system that breaks every time a designer changes stroke width.
Honestly, most teams don't think about icons until the design is done and someone asks "wait, where are these coming from?" That's already too late. If you're building a design system from scratch, the icon library choice is one of the first three decisions you need to make — before your component architecture, arguably before your CSS approach.
Three strategies dominate React projects right now: lucide-react, Heroicons, and fully custom SVGs. They're not interchangeable. Each one has a sweet spot, and understanding that sweet spot will save you a painful migration later.
lucide-react: The Default Choice for a Reason
lucide-react is a community fork of feather-icons that went its own direction, and it's become the de facto standard for React projects that want a coherent icon set without much ceremony. As of v0.460, it ships over 1500 icons, all drawn on a consistent 24×24 grid with a default stroke-width of 2px.
Usage is dead simple. Each icon is a named React component, tree-shakeable by default when you're bundling with Vite or webpack 5. You import what you use, nothing else comes along for the ride.
One more thing — lucide-react accepts standard SVG props plus a size prop and a strokeWidth prop, so you can match your design tokens in one line. That said, the icons are stroked, not filled, which means they read differently at small sizes. Anything below 16px and you'll want to bump strokeWidth down to 1.5.
Quick aside: lucide doesn't include brand logos. If you need GitHub, Twitter, or similar, you'll need a separate package like simple-icons or a custom component. Plan for that gap upfront.
import { Bell, Settings, ChevronRight } from 'lucide-react';
function Header() {
return (
<nav className="flex items-center gap-4">
<Bell size={20} strokeWidth={1.5} className="text-gray-400 hover:text-white" />
<Settings size={20} strokeWidth={1.5} className="text-gray-400 hover:text-white" />
<ChevronRight size={16} className="text-gray-300" />
</nav>
);
}Heroicons: Tailwind's Native Icon Set
Heroicons comes from the Tailwind Labs team. That tells you everything about who it's designed for. If your project is Tailwind-first, Heroicons slots in with zero friction — the icons are designed to match Tailwind's spacing scale, the fill/outline variants map naturally to hover states, and the visual style feels right at home next to Tailwind UI components.
The library ships two variants: outline (24×24 stroke-based) and solid (filled, meant for smaller 20px contexts). In practice, you'd use outline for navigation icons and solid for smaller inline indicators. It's a thoughtful system.
Worth noting: Heroicons v2 covers around 292 icons. That's lean compared to lucide. You will hit gaps, especially in anything data-heavy or dev-tool-adjacent. You can patch those gaps with custom SVGs, but at that point you're running a hybrid system, and hybrid systems have maintenance costs.
import { BellIcon, Cog6ToothIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon } from '@heroicons/react/20/solid';
function StatusRow() {
return (
<div className="flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5 text-green-400" />
<span className="text-sm text-white">Deployment successful</span>
<Cog6ToothIcon className="ml-auto w-5 h-5 text-gray-400" />
</div>
);
}Look, if you're not on Tailwind, Heroicons isn't the right call. The import paths are slightly verbose and the icon count won't cover you. Pick lucide instead.
Custom SVG Components: When You Actually Need Them
Every project hits the moment where neither library has the icon you need. Brand marks, bespoke UI indicators, specialised data viz symbols — they don't live in any open-source icon set, and they never will.
The cleanest pattern for custom SVGs in React is a thin wrapper component. You take the raw SVG markup, strip everything the browser fills in by default, and expose size, color, and className as props. A consistent 24px viewBox is your baseline — everything else scales from there.
// components/icons/ShieldCheckCustom.tsx
interface IconProps {
size?: number;
className?: string;
color?: string;
}
export function ShieldCheckCustom({ size = 24, className, color = 'currentColor' }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M12 2L3 7v5c0 5.25 3.75 10.15 9 11.35C17.25 22.15 21 17.25 21 12V7l-9-5z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
}The aria-hidden="true" isn't optional — it hides the SVG from screen readers and forces you to add meaningful text context in the surrounding component. Do that by default, not as an afterthought.
One more thing — SVGO is your friend. Run your custom SVGs through it before pasting the path data. A designer export from Figma often carries 3–4× the necessary nodes. Stripping that down to clean path data keeps your component files readable and your bundle small.
Mixing Libraries Without Losing Your Mind
Real projects use multiple icon sources. The trick is keeping the visual language consistent. You can mix lucide-react for general UI icons and custom SVGs for brand marks — but you need a shared prop interface so every icon in your codebase behaves the same way.
Build a thin abstraction layer. An <Icon> component that accepts a name prop and resolves to the right component internally is overkill unless you're generating icons dynamically. What you actually want is consistent prop naming across your icon sources: always size, always className, always strokeWidth where applicable.
If you're building components that sit inside a style like the ones on Empire UI's glassmorphism hub, icon consistency matters even more. A stroked 1.5px lucide icon and a filled Heroicons solid icon sitting next to each other in the same card feel wrong. Nail down the visual convention once and document it.
That said, don't over-engineer this. A single ICONS.md doc in your repo listing which library covers which category, and a lint rule against importing from non-approved sources, gets you 90% of the consistency with almost none of the abstraction overhead.
Accessibility and Performance Pitfalls
Two things go wrong with icons more than anything else. First, people forget to handle accessibility. Second, they forget about bundle size until it's too late.
On accessibility: every icon needs either a proper aria-label when it's interactive and stands alone, or aria-hidden="true" when it's decorative. If a <button> contains only an icon with no visible text, that button is invisible to screen readers. Fix it with a visually hidden <span> or an aria-label on the button itself. This isn't a nice-to-have — it's a WCAG 2.1 AA requirement.
On bundle size: lucide-react and Heroicons are both fully tree-shakeable in modern bundlers. Import named exports, not the whole library. import * as Icons from 'lucide-react' in production is a quick way to add 500+ KB to your bundle. If you're using the gradient generator or any other Empire UI tool and your Lighthouse score tanks, check your icon imports first.
Worth noting: if you're on Next.js 14+, dynamic imports for heavy icon sets you only use in one route are worth the two minutes it takes to set up. next/dynamic with { ssr: false } for icon-heavy modal content keeps your initial bundle lean.
Picking the Right Tool for Your Project
The decision tree isn't complicated. Tailwind project with modest icon needs? Heroicons. Everything else? lucide-react. Brand marks and specialised symbols? Custom SVGs, wrapped consistently.
Where teams get tripped up is trying to standardise on one solution when they actually need two. You don't have to pick one library for an entire codebase — you have to pick a consistent interface for how icons behave as components. That's a different problem, and it's much easier to solve.
If your team is building anything that involves distinct visual styles — cyberpunk UI, glassmorphic cards, or neobrutalism layouts like those in the Empire UI component library — icon stroke weight and fill style become part of the design language. A cyberpunk interface wants razor-thin 1px strokes. A claymorphic UI wants filled, rounded shapes. Your icon choice should serve the style, not fight it.
In practice, most production apps end up with lucide-react as the backbone plus 5–15 custom SVGs for brand and domain-specific needs. That's a reasonable place to land. Set up the wrapper pattern once, document your conventions, and move on to the parts of the codebase that actually need your attention.
FAQ
Yes, and a lot of production apps do. Just make sure both are tree-shaken properly and agree on a consistent prop interface across your icon components — size, color, strokeWidth — so they behave uniformly.
Add an aria-label to the button element itself, or use a visually hidden <span> inside it. The SVG should have aria-hidden="true" so screen readers don't double-announce.
Not if you use named imports — import { Bell } from 'lucide-react' is tree-shaken by Vite and webpack 5. Never import the whole library at once unless you want to explain a 500 KB bundle to your team.
Create a dedicated components/icons/ folder, build each brand icon as a typed React component with consistent props (size, className, color), and run the SVG paths through SVGO to strip designer export bloat before committing.