How to Add Custom Icons to Lucide — The Complete 2026 Guide

Learn how to extend Lucide React with your own custom SVG icons using the official createLucideIcon factory, wrapper components, and design-matching techniques.

Amit Yadav
Amit Yadav

Lucide ships 1,500+ icons, but every product eventually needs one it doesn’t have. A brand logo, an industry-specific glyph, a bespoke UI symbol. The question is never whether to add custom icons, it’s how to do it without breaking the consistent feel Lucide is known for.

This guide walks through every approach, from a quick one-off wrapper to a full custom icon package that integrates seamlessly with your existing Lucide usage.

Why Lucide’s consistency matters

Before adding anything custom, it helps to understand what makes Lucide icons look cohesive. Every icon is drawn on a 24x24px grid with a 2px stroke, rounded line caps, and rounded line joins. The visual weight is uniform because the rules are strict.

When you add a custom icon without following these rules, it will visually clash, especially at small sizes where stroke weight differences become obvious. The fix is to design (or optimize) your SVG to match these constraints before writing a single line of React.

The createLucideIcon factory

Lucide exposes a first-class factory function for creating custom icons that are indistinguishable from built-in ones. This is the right tool for almost every use case.

npm install lucide-react

The factory signature:

import { createLucideIcon } from 'lucide-react';

const IconName = createLucideIcon('IconName', [
  // SVG path data tuples: [element, { attributes }]
]);

Each tuple in the array maps to an SVG child element. For most icons, you’ll have one or more path elements.

Full example: a custom “Sparkles Stack” icon

Say you need a stacked-sparkles icon that doesn’t exist in Lucide. Here’s the complete pattern:

import { createLucideIcon } from 'lucide-react';

const SparklesStack = createLucideIcon('SparklesStack', [
  ['path', { d: 'M12 3 L13.5 7.5 L18 9 L13.5 10.5 L12 15 L10.5 10.5 L6 9 L10.5 7.5 Z', key: 'star1' }],
  ['path', { d: 'M18 14 L18.75 16.25 L21 17 L18.75 17.75 L18 20 L17.25 17.75 L15 17 L17.25 16.25 Z', key: 'star2' }],
  ['path', { d: 'M6 14 L6.75 16.25 L9 17 L6.75 17.75 L6 20 L5.25 17.75 L3 17 L5.25 16.25 Z', key: 'star3' }],
]);

export default SparklesStack;

Use it exactly like any other Lucide icon:

<SparklesStack size={24} strokeWidth={2} className="text-yellow-500" />
Every attribute is inherited

Because createLucideIcon wraps your paths in Lucide’s standard <svg> wrapper, all standard props work automatically: size, color, strokeWidth, className, absoluteStrokeWidth, and all SVG event handlers.

Preparing your SVG for Lucide

Raw SVGs from Figma, Illustrator, or icon sites are rarely Lucide-ready. Run through this checklist before converting:

CheckWhat to fix
ViewBox is 0 0 24 24Rescale or re-export at 24px
No hardcoded fill or stroke colorsRemove or set to currentColor
Stroke width is 2pxNormalize all strokes to 2px
Line caps and joins are roundChange in your editor before export
No <g> wrapper groupsFlatten the SVG structure
No unnecessary metadataRun through SVGO

Optimizing with SVGO

Run your SVG through SVGO before extracting the path data:

npx svgo --input icon.svg --output icon.optimized.svg \
  --config '{"plugins":["preset-default",{"name":"removeViewBox","active":false}]}'

Then open the output file and copy the d attribute values from each <path> element.

Strip fill and stroke attributes

SVGO may preserve fill="none" and stroke="currentColor" on paths. Remove these from your path tuples in createLucideIcon because Lucide’s wrapper already applies them at the <svg> level. Keeping them can cause color inheritance to break in dark mode or when color prop is set.

Organizing custom icons at scale

For a handful of one-off icons, a single file works fine. For a design system or large product, you need a proper structure.

src/
  components/
    icons/
      index.ts          ← re-exports everything
      SparklesStack.tsx
      CustomBrand.tsx
      IndustryGlyph.tsx

Your index.ts barrel:

export { default as SparklesStack } from './SparklesStack';
export { default as CustomBrand } from './CustomBrand';
export { default as IndustryGlyph } from './IndustryGlyph';

Then import all your icons, both Lucide and custom, from a single place by re-exporting Lucide too:

// src/components/icons/index.ts
export * from 'lucide-react';
export { default as SparklesStack } from './SparklesStack';
export { default as CustomBrand } from './CustomBrand';

Now every icon in your app comes from one import path:

import { Search, SparklesStack, Bell } from '@/components/icons';
Tree-shaking still works

This pattern is fully tree-shakeable in Vite, Next.js, and Astro. The bundler only includes icons you actually import. Add "sideEffects": false to your package.json if you publish this as a package.

Using SVG files directly (alternative approach)

If you receive icons as .svg files and don’t want to extract paths manually, you can write a thin wrapper that reads the SVG markup at runtime.

import type { LucideProps } from 'lucide-react';

interface SvgIconProps extends LucideProps {
  svgContent: string;
}

export function RawSvgIcon({ svgContent, size = 24, color, strokeWidth = 2, className }: SvgIconProps) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke={color ?? 'currentColor'}
      strokeWidth={strokeWidth}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      dangerouslySetInnerHTML={{ __html: svgContent }}
    />
  );
}

Usage:

const myIconPaths = '<path d="M12 2 L15 8 L22 9 L17 14 L18 21 L12 18 L6 21 L7 14 L2 9 L9 8 Z"/>';

<RawSvgIcon svgContent={myIconPaths} size={20} className="text-primary" />

This approach trades the clean createLucideIcon API for flexibility with raw SVG content, useful when icons come from an API or CMS.

dangerouslySetInnerHTML and XSS

Only use dangerouslySetInnerHTML with SVG content you control. Never inject untrusted user-provided SVG markup; it can execute scripts in some browser contexts.

Matching the Lucide visual style in Figma

If you’re designing custom icons from scratch, set up Figma with these exact constraints to match Lucide:

  1. Frame size: 24x24px
  2. Stroke weight: 2px
  3. Stroke cap: Round
  4. Stroke join: Round
  5. Corner radius on paths: 0 (handle rounding through the stroke cap instead)
  6. Align to pixel grid: On
  7. Export: SVG, no “include id attribute”, no “outline stroke”

The most common mistake is exporting with “outline stroke” turned on in Figma. This converts your 2px stroke into a filled shape, and the resulting SVG path data is much more complex and will look slightly different at small sizes.

Icon accessibility for custom icons

Lucide sets aria-hidden="true" on all icons by default, assuming they’re decorative. For custom icons used as standalone interactive elements (inside a button with no visible label, for example), you need to add an accessible label.

<button>
  <SparklesStack aria-hidden="true" size={20} />
  <span className="sr-only">Generate sparkles</span>
</button>

For a standalone icon that conveys meaning without surrounding text:

<SparklesStack
  role="img"
  aria-label="Sparkles"
  size={20}
/>
The ARIA rule of thumb

If removing the icon would make the UI incomprehensible, it needs an accessible label. If the surrounding text already communicates the meaning, aria-hidden is correct.

Testing your custom icons

Verify three things after adding any custom icon:

1. Visual match at all sizes. Render your icon alongside a built-in Lucide icon at 16px, 20px, 24px, and 32px. The stroke weight should look identical.

2. Dark mode. In dark mode, the icon should invert correctly. If it doesn’t, you have a hardcoded color somewhere in your SVG paths.

3. strokeWidth prop. Change strokeWidth from 1.5 to 2.5 and confirm the icon scales visually. If it stays the same, the stroke is baked into the path shape rather than the SVG stroke attribute.

// Quick visual test component
function IconTest() {
  return (
    <div className="flex gap-4 items-center">
      {[16, 20, 24, 32].map(size => (
        <div key={size} className="flex gap-2 items-center">
          <Search size={size} />
          <SparklesStack size={size} />
        </div>
      ))}
    </div>
  );
}

Frequently asked questions

Can I use createLucideIcon with non-path elements like <circle> and <rect>?

Yes. The tuple format supports any SVG element. A circle icon:

const CircleDot = createLucideIcon('CircleDot', [
  ['circle', { cx: '12', cy: '12', r: '10', key: 'outer' }],
  ['circle', { cx: '12', cy: '12', r: '3', key: 'inner' }],
]);

Does createLucideIcon work with Lucide for Vue or Svelte?

The factory function is only part of lucide-react. Vue and Svelte users should use the defineComponent approach from lucide-vue-next or the component slot pattern in lucide-svelte. Both frameworks have similar extension patterns documented in their respective packages.

My custom icon looks thicker than Lucide icons at small sizes.

This usually means your SVG was exported at a larger viewBox (like 32x32 or 100x100) and not redrawn at 24x24. The stroke width scales with the viewBox, so a 2px stroke on a 32x32 grid looks heavier than a 2px stroke on 24x24 when rendered at the same size.

Share this post