Comparison8 min read

Lucide vs Tabler vs Heroicons: Which Icon Library?

A hands-on comparison of three popular open-source icon sets: real icon counts, the SVG each one renders, bundle cost, accessibility defaults, and when to pick which.

Sanchez Kim·Published June 9, 2026·Updated October 15, 2026

Lucide, Tabler, and Heroicons look similar in a screenshot: outline icons, rounded line ends, drawn on a 24×24 grid. Install them and the differences show up in the details: how many icons each one has in the categories you care about, what markup it renders, what its React components accept, and what it does for accessibility out of the box.

Everything below comes from the packages installed in a real Next.js project: lucide-react 0.460.0, @tabler/icons-react 3.36.1 (plus the raw @tabler/icons SVGs), and @heroicons/react 2.2.0. These are the versions Icony ships; newer releases add icons but keep the same drawing rules. Where I give a number, I say how I got it, so you can re-run the check against newer versions.

The short answer

  • Lucide is the best default for app UI: large enough for most products, very consistent, and it gives you the most control over stroke width.
  • Tabler is the one to pick when coverage matters most: niche objects, file types, and hundreds of brand logos. About one in five outline icons also has a filled twin (1,001 of 4,985).
  • Heroicons is the one to pick when you want a small, carefully drawn set. It comes in four styles across three sizes (24px outline and solid, 20px solid, 16px solid), hides itself from screen readers by default, and fits Tailwind projects well.

The numbers, measured

I counted icon files in each package’s node_modules folder. Lucide’s icon folder has 1,742 files, but 203 of them only re-export another icon under an older name (for example Home is an alias of House), so the real count is the 1,539 files that call createLucideIcon.

  • Lucide 0.460: 1,539 icons. Outline only. ISC license.
  • Tabler 3.36: 4,985 outline icons and 1,001 filled icons (5,986 SVG files). MIT license.
  • Heroicons 2.2: 324 icons, each in 24px outline, 24px solid, and 20px solid; 316 of them also come as 16px solid “micro” icons. MIT license.

Raw totals hide where each set runs thin, so I also counted by name prefix (arrow-* and file-*), using unique icons only: aliases are excluded, and Tabler’s count is outline only. This is rough, since names don’t map perfectly to categories, but the gaps are large enough to matter:

  • Arrows: Lucide 42, Tabler 144, Heroicons 53 (outline).
  • Files and documents: Lucide 58 (file-*), Tabler 95 (file-*), Heroicons 16 (Document*).
  • Brand logos: Tabler has 370 brand-* icons. Lucide still ships 17 (GitHub, Figma, Slack and others), but its type definitions mark them @deprecated with a note that brand icons are due to be removed. Heroicons has none.

If your product needs logos for sign-in buttons or integrations, that decides it before style does: Tabler has them, the other two do not (or will not for long). A dedicated brand set like Simple Icons is the other option.

What each one actually renders

Here is the same “home” icon from each library, rendered to a string with react-dom/server and trimmed slightly for width. The differences are small, but they show:

<!-- Lucide 0.460: <House size={24} /> -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
     stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house">
  <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/>
  <path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>

<!-- Tabler 3.36: <IconHome size={24} /> -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
     stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-home">
  <path d="M5 12l-2 0l9 -9l9 9l-2 0"/>
  <path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7"/>
  <path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6"/>
</svg>

<!-- Heroicons 2.2: <HomeIcon /> (24/outline) -->
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
     aria-hidden="true" data-slot="icon">
  <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621 ..."/>
</svg>
  • Lucide and Tabler render almost the same <svg> attributes: a 2-unit stroke, round caps and joins, fill="none", and currentColor. Put them side by side and the line weight matches. The drawings differ, though: Lucide’s house is one closed outline with rounded corners, while Tabler draws the roof as a separate line whose eaves stick out past the walls.
  • Heroicons uses a 1.5-unit stroke. At the same size, its icons look lighter. Mixing Heroicons with either of the others in one toolbar is the most common way to end up with a row that looks slightly off.
  • Heroicons omits width and height. The icon has only a viewBox, so it takes its size from CSS. Without a class, it grows to fill its container.
  • Tabler’s raw SVG files differ from its React output. The files in @tabler/icons add an invisible bounding-box path and a longer class list that the React components leave out. That path can catch you when you recolor copied files; the icon color guide shows how.

Rendered at the sizes toolbars actually use, the stroke difference is easy to see:

Ten common icons (home, search, settings, user, bell, mail, trash, plus, x, check) from Lucide, Tabler and Heroicons, each at 20px and 24px, enlarged. Heroicons lines look thinner than the other two.
Icony’s SVG export rendered at 20px and 24px, color #111827, each library at its native stroke (Lucide 2, Tabler 2, Heroicons outline 1.5), shown 3× with nearest-neighbor scaling.

Stroke control and variants

All three can be recolored with CSS color, because they all paint with currentColor. Stroke width is where they differ.

  • Lucide takes strokeWidth (default 2) and an absoluteStrokeWidth flag that keeps lines the same pixel width at every icon size. The stroke width guide shows what that does at each size.
  • Tabler calls the prop stroke rather than strokeWidth (default 2). Filled icons are separate components with a Filled suffix, such as IconHomeFilled. Only 1,001 of the 4,985 outline icons have one, so check before you design an active/inactive toggle around filled states.
  • Heroicons has no stroke prop. The outline set is drawn for 1.5, and because extra props are passed through to the <svg>, strokeWidth={2} does work, but the icons were not designed for it. The solid, mini, and micro sets are filled shapes, so stroke width does nothing there.

Heroicons makes up for this with its size variants. The 20px and 16px sets are redrawn, not scaled down. The 16px home icon uses different paths than the 24px one, with fewer details, and at small sizes that beats shrinking a 24px drawing.

Using them in React

The component APIs differ enough that swapping libraries later is a find-and-replace job with some manual fixes:

import { House } from 'lucide-react';
import { IconHome, IconHomeFilled } from '@tabler/icons-react';
import { HomeIcon } from '@heroicons/react/24/outline';
import { HomeIcon as HomeSolid } from '@heroicons/react/24/solid';

export function Nav() {
  return (
    <>
      {/* Lucide: size, color, strokeWidth, absoluteStrokeWidth */}
      <House size={20} strokeWidth={1.75} aria-hidden="true" />

      {/* Tabler: size, color, stroke (not strokeWidth), title */}
      <IconHome size={20} stroke={1.75} aria-hidden="true" />
      <IconHomeFilled size={20} />

      {/* Heroicons: no size prop. Size it with CSS (Tailwind shown) */}
      <HomeIcon className="size-5" />
      <HomeSolid className="size-5 text-indigo-600" />
    </>
  );
}

Naming is the other snag. Lucide calls it house, Tabler calls it home; Lucide has settings, Heroicons has Cog6ToothIcon; Heroicons uses MagnifyingGlassIcon where the others say search. Before you commit, list the 20–30 icons your product actually needs and look each one up in each set.

Bundle cost: measured, and mostly a non-issue

All three packages set "sideEffects": false and ship ES modules, so a bundler includes only the icons you import. To see what that costs, I bundled a file that imports one icon, then another that imports ten common ones (home, search, settings, user, bell, mail, trash, plus, x, check), using esbuild 0.21.5 with minification and React marked external. Gzip sizes are level 9:

// one-icon.jsx
import { House } from 'lucide-react';
export default House;

# bundle it the way a production build would, React left external
npx esbuild one-icon.jsx --bundle --minify --format=esm \
  --external:react --external:react/jsx-runtime --outfile=out.js
wc -c < out.js && gzip -9 < out.js | wc -c
  • 1 icon: Lucide 2,515 B (878 B gzipped), Tabler 2,016 B (744 B), Heroicons 690 B (445 B).
  • 10 icons: Lucide 6,192 B (1,568 B gzipped), Tabler 6,185 B (1,418 B), Heroicons 6,828 B (1,758 B).
Bar chart of gzipped bundle sizes. One icon: Lucide 878, Tabler 744, Heroicons 445 bytes. Ten icons: Lucide 1,568, Tabler 1,418, Heroicons 1,758 bytes. import * as Icons: Lucide 138,020, Tabler 527,852, Heroicons 39,924 bytes.
esbuild 0.21.5, minified ESM, React external, gzip level 9. Named imports on top, import * as Icons on its own scale below.

Lucide and Tabler pay a small, one-time cost for a shared factory function that every icon uses; Heroicons compiles each icon as a standalone component. By ten icons, all three land within 340 bytes of each other gzipped. Bundle size won’t help you choose.

The mistake that does cost you: importing everything

Tree-shaking only works when the bundler can see which names you use. A common pattern for CMS- or database-driven icons, import * as Icons followed by Icons[name], defeats it.

Built the same way as above, that pattern produced 889,675 B (138,020 B gzipped) for Lucide, 232,329 B (39,924 B) for Heroicons outline, and 4,091,519 B (527,852 B) for Tabler (outline and filled together). Use an explicit map instead:

// Pulls in the entire library: the bundler cannot know which names you use
import * as Icons from 'lucide-react';
export const Icon = ({ name, ...props }) => {
  const Cmp = Icons[name];
  return <Cmp {...props} />;
};

// Keeps the bundle small: an explicit map of the icons your data can reference
import { House, Search, Settings } from 'lucide-react';
const ICONS = { house: House, search: Search, settings: Settings };
export const Icon = ({ name, ...props }) => {
  const Cmp = ICONS[name];
  return Cmp ? <Cmp {...props} /> : null;
};

If you really do need any icon by name at runtime (an icon picker, for example), load them lazily. Lucide ships a dynamicIconImports map for this, which puts each icon in its own chunk instead of the main bundle.

Accessibility defaults differ

Most icons next to text are decorative and should be hidden from screen readers with aria-hidden="true". The three libraries handle this differently:

  • Heroicons adds aria-hidden="true" to every icon by default, which is right for decorative use, and keeps it even when you pass a title prop. The accessible icons guide shows what a screen reader gets in that case and how to label the button instead.
  • Lucide 0.460 adds no ARIA attributes. Add aria-hidden="true" yourself on decorative icons, or screen readers may run into an unlabeled graphic.
  • Tabler adds no ARIA attributes either, but accepts a title prop that renders a <title> element inside the SVG.

Four traps when picking a set

  • Mixing stroke weights. Lucide or Tabler at 2 next to Heroicons at 1.5 reads as inconsistent. If you mix sets, set Lucide’s strokeWidth or Tabler’s stroke to 1.5 to match, or the other way around.
  • Assuming a filled version exists. Every Heroicons icon has a solid twin; only about one in five Tabler outline icons does (1,001 of 4,985); Lucide has none.
  • Choosing on total count alone. Heroicons has around a fifth as many icons as Lucide, but if its 324 cover your navigation and actions, the smaller set is easier to browse and more consistent.
  • Building on deprecated brand icons. Lucide’s logos still work today but are marked for removal.

All three are free for commercial use: Lucide is ISC, the other two MIT, and both licenses only ask that the license notice stay with copies of the code, which installing the npm package takes care of. Using two together is common too, usually one main set plus Tabler for logos or niche icons, as long as you match stroke width and size.

How to decide in ten minutes

  1. List the icons your product needs today, including the awkward ones (file types, payment methods, logos).
  2. Look each one up in all three sets and count the misses.
  3. Put your five most-used icons from each set in a real toolbar at the size you ship, not on a white preview grid.
  4. Pick the set with the fewest misses whose weight matches your typography, and fill any gaps from a second set with the stroke matched.

For step 3, Icony has all three libraries (plus Phosphor, Bootstrap Icons, and Radix) in one searchable page, so you can compare the same icon across sets at the same size and stroke before installing anything.

Try it in Icony

Search 10,000+ open-source icons, customize color, size, and stroke, then copy or download as SVG, PNG, or a React component — free.

Open the icon customizer

Related guides

Related icon libraries