DEV Community

Cover image for Relaxicons: Turn Any Iconify Icon into a Native Component for 8 Frameworks
Ravi Kishan
Ravi Kishan

Posted on Originally published at ravikishan.me

Relaxicons: Turn Any Iconify Icon into a Native Component for 8 Frameworks

TL;DR β€” Relaxicons is a Node.js CLI that pulls any icon from the Iconify catalogue (200+ open-source icon sets) and writes it into your project as a real component for React, Next.js (RSC), Vue, Angular, Svelte, Solid, Laravel Blade or Web Components. One command, one file, no runtime icon library.

npm install -g relaxicons
relaxicons init
relaxicons add lucide:home

πŸ”— GitHub Β· πŸ“¦ npm Β· πŸ“š Docs Β· πŸ” Icon Explorer


Table of Contents

  1. The problem: icons are weirdly annoying
  2. The idea: shadcn, but for icons
  3. A 60-second tour
  4. What happens when you run relaxicons add
  5. The SVG transform pipeline (and the outline-icon trap)
  6. One prop contract, eight frameworks
  7. Caching that respects the network
  8. The small details that make a CLI pleasant
  9. Icons in CI
  10. The docs site
  11. Testing
  12. What I learned
  13. What's next

The problem: icons are weirdly annoying

Every frontend project hits the same wall sooner or later. You need one home icon. Your options are usually:

  • Install a whole icon package (react-icons, @mdi/js, lucide-react…). Great until you want an icon from a different set, and now you have two dependencies, two APIs and two styling conventions.
  • Use a runtime icon loader that fetches SVGs from a CDN in the browser. Flexible, but now your icons depend on a network request at render time.
  • Copy-paste the SVG from a website. Fast, but you end up with hard-coded width="24", a random fill="#000", kebab-case attributes that React complains about, and zero consistency between files.

None of these felt right to me. I wanted the icon in my repo, as code I own, written in the idiom of whatever framework I'm using β€” and I wanted it from any icon set without thinking about it.

That's why I built Relaxicons.

The idea: shadcn, but for icons

The mental model is close to how shadcn/ui treats components: don't install a library, generate the source into your project.

Iconify already did the heavy lifting of collecting icon sets β€” Lucide, Material Design Icons, Tabler, Phosphor, Font Awesome, Heroicons, and hundreds more β€” behind a single, consistent API. Every icon has an ID like collection:name:

lucide:home
mdi:github
tabler:brand-react
ph:rocket-launch
Enter fullscreen mode Exit fullscreen mode

Relaxicons sits on top of that API. You give it an ID, and it gives you back a component file that:

  • has no width/height baked in (it scales with a size prop, default 1em),
  • uses currentColor so it inherits your text color,
  • exposes the same props in every framework (size, color, strokeWidth, className/class),
  • is formatted with Prettier, and
  • is automatically exported from an index.ts barrel file.

No runtime. No icon package. Just a file.

A 60-second tour

Install the CLI globally (Node 18+):

npm install -g relaxicons
relaxicons --version   # 1.1.2
Enter fullscreen mode Exit fullscreen mode

Initialize inside your project. Relaxicons sniffs your project to figure out what you're using:

relaxicons init
# βœ” Detected: Next.js
# ? Confirm framework β€Ί Next.js
# ? Where do you want to save icons? β€Ί components/ui/icons
# ? Do you use TypeScript? β€Ί yes
# βœ” Created relaxicons.config.json
Enter fullscreen mode Exit fullscreen mode

That writes a small config:

{
  "framework": "next",
  "iconPath": "components/ui/icons",
  "typescript": true,
  "schemaVersion": 2,
  "generatedAt": "2026-10-05T00:00:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

Now add icons β€” one, several, or a whole file of them:

relaxicons add lucide:home
relaxicons add lucide:home,star,bell          # shared prefix shorthand
relaxicons add mdi:github,tabler:brand-react   # mix collections
relaxicons add --from icons.txt                # batch from a file
Enter fullscreen mode Exit fullscreen mode

And use them like any other component:

import { HomeIcon, StarIcon } from '@/components/ui/icons';

export default function Nav() {
  return (
    <nav className="text-slate-600">
      <HomeIcon />                         {/* inherits text color, 1em */}
      <StarIcon size={24} color="gold" />  {/* explicit size + color */}
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

Need to find an icon first? Explore from the terminal:

relaxicons collections --filter luc
relaxicons icons lucide --filter clock --limit 10
relaxicons search arrow --collection lucide
relaxicons stats -c lucide
Enter fullscreen mode Exit fullscreen mode

…or use the visual Icon Explorer on the docs site and copy the command straight from there.

What happens when you run relaxicons add

Under the hood, add is a small, linear pipeline. Here's the whole journey of relaxicons add lucide:home:

  lucide:home
      β”‚
      β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   relaxicons.config.json β†’ framework, iconPath, typescript
β”‚  getConfig   β”‚   (--framework flag can override per call)
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   GET https://api.iconify.design/lucide/home.svg
β”‚  fetchIcon   β”‚   404 β†’ fuzzy "Did you mean…?" suggestions
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   optional, only if svgo is installed
β”‚  SVGO        β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   strip width/height, currentColor, drop data-* attrs
β”‚ transformSvg β”‚   β†’ { attrs, children }
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   custom template (.hbs/.ejs/.js) if present,
β”‚  template    β”‚   otherwise built-in react/vue/angular/svelte/…
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   parser picked from file extension
β”‚  Prettier    β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   HomeIcon.tsx + sorted `export * from './HomeIcon'`
β”‚ write+barrel β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

A few parts of that are worth zooming into.

Parsing the ID

fetchIcon accepts both lucide:home and lucide/home, and rejects anything that isn't exactly two non-empty parts with a helpful message. It then validates that the response actually is an SVG (optionally preceded by an XML prolog) before trusting it β€” if Iconify ever returns an HTML error page, you get Unexpected response: not an SVG instead of a broken component.

Safe names

Icon names on Iconify are wild. Some start with digits (500px), some are JavaScript reserved words (default, class, function), some are pure symbols. A generated component must be a valid identifier, so names go through two passes:

toPascalCase('arrow-right')  // ArrowRight
safePascal('Default')        // fine β€” PascalCase isn't reserved
safePascal('500px')          // Icon500px  (leading digit)
safePascal('')               // Icon       (nothing left after cleaning)
safeKebab('ÅÄÖ')             // icon
Enter fullscreen mode Exit fullscreen mode

The same sanitized names are reused by relaxicons remove, so removing an icon always finds the file add created.

The SVG transform pipeline (and the outline-icon trap)

This is the most interesting file in the project: src/utils/transformSvg.js. It loads the SVG with cheerio in XML mode and does four things:

  1. Removes width and height from the root, keeping viewBox, so the component can scale freely.
  2. Applies currentColor so icons inherit the surrounding text color.
  3. Strips noise like data-name / data-style attributes left behind by design tools.
  4. Returns a neutral representation β€” { attrs, children } β€” that every framework template consumes.

Step 2 sounds trivial ("just set fill="currentColor" everywhere"), and that's exactly the trap I fell into first.

Icon sets come in two flavours:

Style Examples How it's drawn
Filled MDI, Material Symbols, Font Awesome Shapes painted with fill
Outline Lucide, Feather, Tabler fill="none" + stroke, lines only

If you blindly add fill="currentColor" to every <path> in an outline icon, the strokes get filled in and your crisp line icon turns into a solid blob. So the transform first asks: is this an outline icon?

const rootFillNone = $svg.attr("fill") === "none";
const rootHasStroke = !!$svg.attr("stroke");
const isOutline = rootFillNone || rootHasStroke;
Enter fullscreen mode Exit fullscreen mode

…and only then decides, per element:

  • element has a stroke-width but no stroke β†’ give it stroke="currentColor";
  • element already has its own fill (even fill="none") β†’ never touch it;
  • outline icon, or the element itself is stroke-based β†’ leave fill unset so it inherits none;
  • otherwise, for real shapes (path, circle, rect, polygon, polyline, ellipse) β†’ fill="currentColor".

The trap had a second layer, and I only found it while writing this post. The Iconify API doesn't put Lucide's fill="none" on the root at all. It wraps every path in a group:

<svg viewBox="0 0 24 24">
  <g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <path d="M15 21v-8a1 1 0 0 0-1-1h-4…"/>
    <path d="M3 10a2 2 0 0 1 .709-1.528l7-6…"/>
  </g>
</svg>
Enter fullscreen mode Exit fullscreen mode

A root-only check sees no outline, so relaxicons add lucide:home produced a filled house. Worse, the group's stroke-width="2" shadowed the component's strokeWidth prop. The fix does two things:

  • Hoist the wrapper. If the root has exactly one <g> child that only carries presentation attributes (fill, stroke, stroke-width, line caps/joins), those move onto the root and the group is unwrapped. Now the root really says fill="none" stroke="currentColor", and strokeWidth defaults to the icon's own 2.
  • Check ancestors, not just the root. If a group can't be hoisted (say it also has a transform), any element inside a fill="none" or stroked group is left alone instead of being filled.

With both in place, filled icons recolor correctly and outline icons stay outlines, whichever way the SVG is structured.

One prop contract, eight frameworks

Every template lives in src/templates/ and shares helpers from _shared.js. The key design decision: every framework gets the same props.

Prop Default What it does
size 1em Sets width and height
color inherited Applied as CSS color β€” which currentColor resolves to
strokeWidth the icon's own value Overrides stroke width when passed
className / class β€” Merged onto the root <svg>

Notice that color is applied through CSS color, not by forcing a fill. That one choice means color="red" recolors filled icons and outline icons without ever changing what kind of icon it is.

Here's what that contract looks like in each ecosystem:

React / Next.js β€” named + default export, optional TypeScript props type, and an RSC-safe variant (--framework next-rsc) with no hooks and no 'use client':

export function HomeIcon({ size = '1em', color, strokeWidth, className, style, ...props }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
         width={size} height={size} strokeWidth={strokeWidth}
         className={className} style={color ? { color, ...style } : style} {...props}>
      <path d="…" />
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

Vue β€” a Single File Component with real typed props and inheritAttrs: false, forwarding everything else via v-bind="$attrs":

<template>
  <svg viewBox="0 0 24 24" :width="size" :height="size" :stroke-width="strokeWidth"
       :style="color ? { color } : undefined" v-bind="$attrs">
    <path d="…" />
  </svg>
</template>
Enter fullscreen mode Exit fullscreen mode

Angular β€” a standalone component with @Input()s and a selector like icon-home:

@Component({ selector: 'icon-home', standalone: true, template: `<svg …>…</svg>` })
export class HomeIcon {
  @Input() size: string | number = '1em';
  @Input() color?: string;
}
Enter fullscreen mode Exit fullscreen mode

Laravel Blade β€” @props([...]) with $attributes->merge(), used as <x-home-icon size="24" color="red" />.

Svelte β€” exported props plus $$restProps; class is aliased because it's a reserved word in JS.

Solid β€” JSX with splitProps so known props are separated from the rest.

Web Components β€” a framework-free custom element with Shadow DOM, observing size, color, stroke-width and class:

<script type="module" src="./icons/home.js"></script>
<icon-home size="32" color="tomato"></icon-home>
Enter fullscreen mode Exit fullscreen mode

Getting the markup idiomatic per framework is where most of the subtle bugs hide. A few I had to handle in _shared.js and the templates:

  • React needs camelCase attributes (stroke-linecap β†’ strokeLinecap, class β†’ className, xlink:href β†’ xlinkHref) β€” but only attribute names, never path data.
  • Angular embeds the SVG in a template literal, so backticks, backslashes and ${ inside the markup must be escaped.
  • Web Components embed the markup as a JS string, so it goes through JSON.stringify to survive quotes and newlines.
  • Custom element names must contain a hyphen β€” hence the icon- prefix.

And if none of the built-ins fit your codebase, drop a react.hbs, vue.ejs or svelte.js into a templatesDir and Relaxicons will use your template instead, passing it { iconId, pascal, kebab, svg, typescript }. Handlebars and EJS are lazy-required, so they're only needed if you actually use them.

Caching that respects the network

Listing collections and searching icons means hitting Iconify's metadata endpoints a lot. src/utils/cache.js makes that cheap and resilient:

  • Responses are cached as JSON in ~/.cache/relaxicons (override with RELAXICONS_CACHE_DIR).
  • Each entry stores the ETag; the next request sends If-None-Match, and a 304 Not Modified just refreshes the timestamp.
  • Entries without an ETag use a 24-hour TTL.
  • Requests retry with backoff (250 ms β†’ 500 ms β†’ 1 s) on network errors, 429 and 5xx, and honour the server's Retry-After header.
  • If the network fails entirely, it falls back to stale cache instead of crashing.
  • RELAXICONS_OFFLINE=1 makes the CLI run purely from a warm cache β€” handy on a plane, and essential for deterministic tests.
relaxicons update-cache   # warm everything
relaxicons cache-clear    # start fresh
Enter fullscreen mode Exit fullscreen mode

The small details that make a CLI pleasant

Most of the work in a CLI isn't the happy path β€” it's everything around it.

"Did you mean…?" β€” typo an icon name and Relaxicons loads the collection and ranks names by similarity:

βœ– Icon not found
Did you mean: alarm-clock, alarm-check, alarm-off?
Enter fullscreen mode Exit fullscreen mode

If nothing in that collection is close, it searches across collections for the nearest match.

Framework auto-detection β€” init looks for next.config.*, vite.config.* + a react/vue/svelte dependency, angular.json, or composer.json + artisan. Each match carries a confidence score and the highest wins (Astro is detected at low confidence so a docs folder never overrides your real app).

Always-sorted barrels β€” every add appends an export and re-sorts the barrel alphabetically, preserving comment headers and the file's original line endings (CRLF stays CRLF on Windows).

Batch-friendly errors β€” in a batch, a missing icon or an existing file doesn't abort the run; it's reported and the rest continue.

Meaningful exit codes, so scripts can react:

Code Meaning
0 OK
1 Generic error
2 Config missing / invalid
3 Fetch failed
4 File already exists

And the usual polish: --dry-run to preview writes, --quiet and --no-color for CI logs, --raw for plain SVG, --both for component + cleaned SVG side by side, doctor to check Node, config and Iconify reachability, and a JSON Schema (relaxicons.config.schema.json) so your editor autocompletes the config.

Icons in CI

For teams, I wanted icons to be reproducible. List the icons your app uses in a manifest:

# relaxicons.manifest
lucide:home
lucide:star
mdi:github
Enter fullscreen mode Exit fullscreen mode

…and regenerate them anywhere:

relaxicons regenerate -m relaxicons.manifest --concurrency 4
Enter fullscreen mode Exit fullscreen mode

One fun bug from building this: the first version ran several add commands concurrently inside the same process by re-parsing the shared Commander instance. Commander keeps per-invocation state on that object, so parallel runs corrupted each other. The fix was to run each add in an isolated child process with a small worker pool β€” boring, but bulletproof.

The repo ships a ready-made GitHub Action that runs on changes to the manifest or config, regenerates the icons and commits the result:

- run: npm i -g relaxicons
- name: Regenerate icons
  run: relaxicons regenerate -m relaxicons.manifest
- name: Commit changes
  run: |
    git add -A
    git diff --cached --quiet || git commit -m "chore(relaxicons): regenerate icons"
    git push
Enter fullscreen mode Exit fullscreen mode

The docs site

The documentation at ravikisha.github.io/relaxicons is built with Astro 5, React islands, Tailwind CSS 4 and Radix UI primitives, and deployed to GitHub Pages by the CI workflow on every push to main.

Some highlights:

  • Icon Explorer β€” browse collections, search icons, keep Favorites and Recent tabs, navigate with arrow keys + Enter, and copy an icon as a CLI command, raw SVG or data URI. It also shows per-collection stats and a top-icons carousel.
  • Command palette (built with cmdk) for jumping anywhere in the docs.
  • Copy-ready code tabs for every framework, an animated framework marquee and a live mini demo on the home page.

Good starting points in the docs:

Testing

The project has 24 Jest suites covering each piece in isolation and the CLI end to end:

  • Unit tests for naming, sanitizing, path handling, framework detection, config validation, the SVG transform, and every framework template.
  • Golden tests that snapshot generated output, so a template change can't silently alter what users get.
  • Integration tests that spawn the real CLI in temp directories with RELAXICONS_OFFLINE=1 and fixture icons β€” no network, no flakiness β€” and assert on files written and exit codes.
  • Windows path tests, because a CLI that writes files had better work with backslashes too.
npm test
npm run coverage
Enter fullscreen mode Exit fullscreen mode

What I learned

  • SVG is not one format, it's a family of conventions. Outline vs. filled, attributes on the root vs. on a <g>, fill="none" meaning "inherit nothing". Normalizing icons from 200+ sets is way subtler than "replace the color", and testing against the real API response (not a hand-written fixture) is what caught the <g> wrapper bug.
  • Generating code means respecting each framework's idioms. It's easy to produce something that renders; it's harder to produce something a React, Vue or Angular developer would actually want to commit.
  • Caching is a UX feature. ETags, TTLs, backoff and stale fallback are what make a network-backed CLI feel instant and never strand you offline.
  • Shared mutable state bites, even in a CLI. The Commander concurrency bug was a good reminder that "it's just a script" doesn't exempt you from isolation.
  • Exit codes and --dry-run are documentation. They tell scripts and humans exactly what happened.

What's next

  • Real shell completions for zsh/bash/fish (the completion command is still a stub).
  • Removing the deprecated list alias in v2.0 (use icons instead).
  • More framework targets and richer custom-template examples.

If you're tired of juggling icon packages, give it a spin:

npm install -g relaxicons
relaxicons init
relaxicons add lucide:sparkles
Enter fullscreen mode Exit fullscreen mode

⭐ Star it on GitHub, grab it from npm, and if you find an icon that renders wrong, open an issue β€” I'd love to see it.

Happy building! 🎨

Top comments (0)