TL;DR: React Icons repackages roughly thirty icon families as React components behind one dependency. Import from the family's subpath so tree-shaking works, size and color follow CSS by default, and nothing loads a font file. Browser: react-icons.github.io/react-icons.
What is React Icons?
React Icons is a build of many popular icon sets into a single npm package, with each set behind its own import path. Font Awesome, Material Design, Bootstrap, Feather, Heroicons, Lucide, Simple Icons, Phosphor, Remix and others are all present, and each icon arrives as an ordinary React component rendering inline SVG.
The design goal is that adding an icon costs one import and roughly a kilobyte, rather than a font file, a sprite sheet, or a build step.
When to use it
Use it when a product needs icons from more than one family. That happens more often than it sounds: a general UI set for the interface, plus brand marks for third-party logins, plus a technical set for file types. Installing three packages and reconciling three prop APIs is worse than one.
Use something else when you know your family. If the design system says Lucide, lucide-react gives you the same icons with a smaller dependency graph and faster type checking, because your editor isn't indexing thirty sets.
Key Features
- Around thirty icon families behind one dependency
- Inline SVG components, so no font loading and no FOUT
1emsizing andcurrentColorfill, so icons follow surrounding textIconContext.Providerfor setting size, color and className across a subtree- Per-set import paths that let bundlers drop everything you don't use
- Types included, so every icon is a typed component
Installation
npm install react-icons
Import from the two-letter subpath of the set, never from the package root:
import { FaGithub } from 'react-icons/fa';
import { LuSettings } from 'react-icons/lu';
Using Icons
import { FiTrash2, FiEdit2 } from 'react-icons/fi';
export function RowActions({ onEdit, onDelete }: {
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="row-actions">
<button onClick={onEdit} aria-label="Edit row">
<FiEdit2 />
</button>
<button onClick={onDelete} aria-label="Delete row">
<FiTrash2 />
</button>
</div>
);
}
Both buttons carry the accessible name. The icons stay silent, which is what you want when the control already says what it does.
Setting Defaults For a Subtree
import { IconContext } from 'react-icons';
export function Toolbar({ children }: { children: React.ReactNode }) {
return (
<IconContext.Provider value={{ size: '1.25rem', className: 'toolbar-icon' }}>
{children}
</IconContext.Provider>
);
}
This is the cleanest way to keep icon sizing consistent without repeating a prop on every instance.
TypeScript Tips
Every icon is an IconType, which is a function component taking IconBaseProps. That makes icons easy to pass around as data:
import type { IconType } from 'react-icons';
import { FiHome, FiUsers } from 'react-icons/fi';
type NavItem = { label: string; href: string; icon: IconType };
const nav: NavItem[] = [
{ label: 'Home', href: '/', icon: FiHome },
{ label: 'Team', href: '/team/', icon: FiUsers },
];
Render with <item.icon />. Typing the field as IconType rather than ReactNode keeps the component uninstantiated, so the consumer decides its size.
How do you style icons from CSS?
The icons render as inline SVG with stroke="currentColor", either fill="currentColor" or fill="none", and a height and width of 1em. So the two properties you actually use are font-size for size and color for colour, and both inherit like they do for text.
.icon-button {
display: inline-grid;
place-items: center;
min-inline-size: 2.25rem;
min-block-size: 2.25rem;
font-size: 1.25rem;
color: var(--ink);
transition: color 120ms ease;
}
.icon-button:hover {
color: var(--accent);
}
Rendering a few families with react-icons 5.7.0 and reading the markup shows that they aren't built the same way:
| Family | How it paints | Weight control |
|---|---|---|
Font Awesome (fa), Material (md), Bootstrap (bs), Phosphor (pi), Remix (ri) | fill="currentColor", stroke-width="0" | none, the shapes are solid outlines |
Feather (fi), Lucide (lu), Tabler (tb) | fill="none", stroke="currentColor", stroke-width="2", round caps and joins | strokeWidth prop |
Heroicons 2 outline (hi2) | fill="none", stroke="currentColor", stroke-width="1.5" | strokeWidth prop |
Two things follow. A line icon with a CSS fill: red rule turns into a filled blob, so colour line icons through color or stroke and leave fill alone. And mixing a stroke family with a solid family in one toolbar gives you visibly different weights, which no prop will fix; that's the real reason to pick a primary set.
Two prop traps are worth knowing. The color prop doesn't only set a colour: it writes an inline style="color:...", and an inline style beats your .icon-button:hover rule, so the hover never shows. Leave color off and let it inherit. The size prop writes height and width attributes, which means font-size stops controlling the icon; use size or font-size for a given component, not both.
When you need to set something on every icon below a point in the tree, IconContext.Provider takes size, color, className, style and attr, where attr is merged onto the <svg> element.
How do you make icons accessible?
Out of the box, they aren't, and the defaults aren't even consistent. Rendering FiTrash2 or FaGithub gives you an <svg> with no aria-hidden, no role and no title. Rendering HiOutlineTrash from the Heroicons 2 set gives you the same <svg> with aria-hidden="true" already on it. Same package, same import style, different behaviour.
Three situations cover almost everything:
| Situation | What to do | Accessible name |
|---|---|---|
| Icon next to visible text | hide the icon: aria-hidden="true" | the text |
| Icon is the only content of a button | label the button: aria-label="Delete row" | the label |
| Icon stands alone as an image | role="img" and aria-label on the icon | the label |
I checked these with dom-accessibility-api, the same name computation Testing Library uses, in jsdom. An icon-only button with nothing on it computes to an empty name, which is the failure you want to avoid. A title prop does produce a name, since it renders a <title> child, and aria-label on the control did too. I haven't run it through NVDA or VoiceOver here, and screen readers treat SVG <title> less consistently than a label on the control, so I'd put the label on the button and keep title for the tooltip.
The package won't enforce any of this, so enforce it once in a wrapper:
import type { IconType } from 'react-icons'
type IconProps = { icon: IconType; label?: string }
export function Icon({ icon: Glyph, label }: IconProps) {
if (label) {
return <Glyph role="img" aria-label={label} />
}
return <Glyph aria-hidden="true" focusable="false" />
}
An icon without a label is hidden, an icon with one is announced, and a developer has to choose. Used as <Icon icon={FiTrash2} label="Delete row" /> it computed to "Delete row", and <Icon icon={HiOutlineTrash} /> Delete to "Delete".
A shortcut tempts people here: attr={{ 'aria-hidden': 'true' }} on an IconContext.Provider hides every icon below it. That works for a toolbar whose buttons all carry text, and it silently hides every meaningful standalone icon in the same subtree.
Two WCAG 2.2 criteria matter beyond the name. Non-text contrast (1.4.11) asks for a 3:1 contrast ratio for icons that carry information, so a pale grey currentColor on white fails even though it looks tasteful. And target size (2.5.8, AA) asks for at least 24 by 24 CSS pixels for a pointer target, with a few exceptions such as well-spaced small targets. A bare 1em icon at 16px fails that by itself, which is what the min-inline-size and min-block-size in the CSS above are for.
How do you check tree-shaking actually worked?
Importing from the subpath is the rule, and rules you cannot verify get broken silently during a refactor. So measure it once.
npx vite build
npx source-map-explorer 'dist/assets/*.js'
What you want to see is a handful of tiny SVG component functions, each a few hundred bytes. What tells you something went wrong is a react-icons block measured in hundreds of kilobytes, which means a barrel import survived somewhere.
The fastest way to find the offender without reading the whole map:
grep -rn "from 'react-icons'" src/
Anything that match returns is a barrel import. The subpath version ends in the set, from 'react-icons/fa6', and that one character of difference is the whole optimisation. I'd add that grep to a pre-commit hook on any project with more than two developers, because it costs nothing and it catches the mistake at the only moment it is cheap to fix.
When should you use a dedicated package instead?
React Icons earns its place when you genuinely draw from several families, or when you do not yet know which family you will settle on. It stops earning it the moment you do know.
| Situation | Better choice |
|---|---|
| Prototyping, family undecided | React Icons |
| Mixing two or three families deliberately | React Icons |
| Settled on one family | that family's own package |
| Need consistent stroke weight across the whole UI | a single set such as Lucide |
The trade is editor performance and API surface against convenience. Thirty sets of type declarations is a real cost in a large TypeScript project, and it shows up as sluggish autocomplete rather than as an error, which makes it easy to blame on something else. Once your design has settled on one family, the dedicated package gives you faster tooling and a smaller dependency for the same rendered output.
My rule: start here, migrate out when the design system stops changing its mind. Migrating is a find-and-replace on import paths, so the cost of starting with React Icons and leaving later is genuinely low. That asymmetry is why it is the right default rather than a compromise.
Before you pick a primary set, run five checks against the shortlist: does it cover the icons you'll actually need (search for ten, not one); is the stroke weight consistent across the set; does the import path tree-shake in your bundler (the measurement above); what accessibility defaults does it ship, given the aria-hidden difference between families; and what does it cost in editor responsiveness. Only the last two are invisible in a demo, which is why they decide more migrations than they should.
Common Gotchas
The big one is the barrel import. import { FaGithub } from 'react-icons' looks equivalent to the subpath version and is not; depending on your bundler it can pull far more than one icon. Always import from the set.
The second is editor performance. Thirty sets is a lot of type declarations, and autocomplete in a large project can get sluggish. If you have settled on one family, moving to that family's own package is the fix.
The third is duplicated concepts across sets. Nothing stops you using FaTrash in one component and FiTrash2 in another, and the mismatch is visible. Pick a primary set and treat the others as exceptions.