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
- The problem: icons are weirdly annoying
- The idea: shadcn, but for icons
- A 60-second tour
- What happens when you run
relaxicons add - The SVG transform pipeline (and the outline-icon trap)
- One prop contract, eight frameworks
- Caching that respects the network
- The small details that make a CLI pleasant
- Icons in CI
- The docs site
- Testing
- What I learned
- 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 randomfill="#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
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
sizeprop, default1em), - uses
currentColorso 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.tsbarrel 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
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
That writes a small config:
{
"framework": "next",
"iconPath": "components/ui/icons",
"typescript": true,
"schemaVersion": 2,
"generatedAt": "2026-10-05T00:00:00.000Z"
}
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
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>
);
}
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
β¦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 β
ββββββββββββββββ
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
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:
-
Removes
widthandheightfrom the root, keepingviewBox, so the component can scale freely. -
Applies
currentColorso icons inherit the surrounding text color. -
Strips noise like
data-name/data-styleattributes left behind by design tools. -
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;
β¦and only then decides, per element:
- element has a
stroke-widthbut nostrokeβ give itstroke="currentColor"; - element already has its own
fill(evenfill="none") β never touch it; - outline icon, or the element itself is stroke-based β leave
fillunset so it inheritsnone; - 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>
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 saysfill="none" stroke="currentColor", andstrokeWidthdefaults to the icon's own2. -
Check ancestors, not just the root. If a group can't be hoisted (say it also has a
transform), any element inside afill="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>
);
}
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>
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;
}
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>
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.stringifyto 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 withRELAXICONS_CACHE_DIR). - Each entry stores the ETag; the next request sends
If-None-Match, and a304 Not Modifiedjust 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,
429and5xx, and honour the server'sRetry-Afterheader. - If the network fails entirely, it falls back to stale cache instead of crashing.
-
RELAXICONS_OFFLINE=1makes 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
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?
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
β¦and regenerate them anywhere:
relaxicons regenerate -m relaxicons.manifest --concurrency 4
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
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:
- Getting Started
- CLI Reference
- Configuration
- Framework Adapters
- Transform Pipeline
- Advanced Guide
- Troubleshooting Β· FAQ
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=1and 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
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-runare documentation. They tell scripts and humans exactly what happened.
What's next
- Real shell completions for zsh/bash/fish (the
completioncommand is still a stub). - Removing the deprecated
listalias in v2.0 (useiconsinstead). - 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
β 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)