How to how to build a scalable svg icon component library in vue 3
- Step 1Export and clean icons from Figma — Export SVGs from Figma, then run each through SVG Metadata Scrubber (drops
<metadata>, editor IDs) and SVG Pro Minifier (collapses whitespace, shortens paths). Store the clean files inpackages/icons/src/assets/with consistent kebab-case names. - Step 2Make colours themeable BEFORE wrapping — The converter does not touch your colours. Run SVG to Tailwind in
currentColormode (or SVG CSS Variable Injector) so hard-coded fills becomecurrentColor/ CSS variables. Now thecolorprop and CSScolorcan actually drive the icon. - Step 3Generate the Vue SFC per icon — Run SVG to Vue/Svelte with
framework: vue, TypeScript on, andcomponentNameset from the filename. You get<template>+<script setup>withdefineProps<{ size?; color?; class? }>(). Name it consistently (e.g.IconArrowLeft). - Step 4Wire the size prop in the template — The tool leaves the SVG's literal
width/heightin place. Edit the generated<template>to bind them:<svg :width="size" :height="size" ...>. WithcurrentColoralready in place from step 2,colorflows via CSS — or bind:style="{ color }"if you prefer a prop. - Step 5Assemble named exports for tree-shaking — Create a barrel
index.tsthat re-exports each icon as a named export:export { default as IconArrowLeft } from './IconArrowLeft.vue'. Named exports let bundlers drop unused icons, so consumers only pay for what they import. - Step 6Publish and consume in Nuxt 3 — Publish as
@company/icons. In a Nuxt 3 app, register the package's components directory for auto-import (components: [{ path: '@company/icons/components', prefix: 'Icon' }]) so<IconArrowLeft />works without manual imports, rendered inline server-side.
Design-system pipeline: tool per stage
Each stage maps to a JAD SVG tool. The converter sits in the middle; the steps around it are what make the icons themeable and small.
| Stage | Tool | Why it matters for a design system |
|---|---|---|
| Strip editor cruft | svg-metadata-scrubber | Removes Figma IDs/metadata so committed components are clean diffs |
| Shrink geometry | svg-pro-minifier | Smaller path data = smaller component files and bundles |
| Make colours themeable | svg-to-tailwind | currentColor mode lets CSS/color recolour icons — the converter won't |
| Variable-driven colours | svg-css-variable-injector | Maps colours to CSS vars for multi-tone, token-driven icons |
| Wrap as Vue SFC | svg-to-vue-svelte | Produces the <script setup> component shell with typed props |
What you get vs what you must add
The converter handles the component shell; the design-system polish is on you. Knowing the split prevents shipping props that silently do nothing.
| Design-system requirement | Converter does it? | Your step |
|---|---|---|
| Named, typed component per icon | Yes | Name via componentName; TS on |
size prop resizes the icon | No (declared, unbound) | Bind :width="size" :height="size" |
color prop / theme recolours | No (colours kept literal) | Convert to currentColor first; CSS color drives it |
| Tree-shakeable exports | No (single file out) | Author a barrel of named exports |
class styling pass-through | Yes (Vue fallthrough) | Nothing — works automatically |
Accessibility (aria-hidden, title) | No | Add attributes in the SFC manually |
Cookbook
The Figma-to-Nuxt path, with the two manual steps (theming, size binding) that the converter intentionally leaves to you.
Clean a Figma export before wrapping
Figma SVGs carry IDs and metadata. Scrub and minify so the committed component is just geometry.
figma export: arrow-left.svg (with <metadata>, ids) -> svg-metadata-scrubber -> removes editor cruft -> svg-pro-minifier -> collapses whitespace, paths Result: a tight <svg> ready to wrap, tiny git diff.
Convert colours to currentColor (so the theme can drive them)
The converter keeps colours literal. Make them theme-aware FIRST or the color prop and dark mode won't work.
Before (literal): <path fill="#1f2937" .../> -> svg-to-tailwind (currentColor mode) After: <path fill="currentColor" .../> Now CSS color or a CSS variable recolours the whole icon.
Generate the Vue SFC and bind size
The tool emits the shell; you add one binding so the size prop is real. This is the step most teams forget.
Converter output (IconArrowLeft.vue):
<!-- Generated component: icon-arrow-left -->
<template>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">...</svg>
</template>
<script lang="ts" setup>
defineProps<{ size?: number | string; color?: string; class?: string }>()
</script>
Edit the template -> bind size:
<svg :width="size" :height="size" viewBox="0 0 24 24"
fill="currentColor" :style="{ color }">...</svg>Barrel file for tree-shaking
Named exports let bundlers drop unused icons. Without this, importing one icon can drag in the whole set.
// packages/icons/src/index.ts
export { default as IconArrowLeft } from './IconArrowLeft.vue'
export { default as IconSearch } from './IconSearch.vue'
export { default as IconCheck } from './IconCheck.vue'
// Consumer (tree-shaken):
import { IconSearch } from '@company/icons'Nuxt 3 auto-import + usage
Register the package's components dir; Nuxt auto-imports with a prefix and renders inline server-side.
// nuxt.config.ts
export default defineNuxtConfig({
components: [
{ path: '@company/icons/components', prefix: 'Icon' }
]
})
<!-- Anywhere in a Nuxt page, no import needed: -->
<IconArrowLeft :size="20" class="text-blue-500" />
<!-- size resizes (bound), text-blue-500 recolours via
currentColor + Vue class fallthrough -->Edge cases and what actually happens
Icons don't recolour with the theme
Missing prep stepIf your icons stay one colour in dark mode, you skipped the currentColor conversion. The wrapper keeps literal fill/stroke, so color/CSS can't override them. Run SVG to Tailwind (currentColor mode) on the source before wrapping, then re-generate.
size prop has no effect
Unbound by designThe converter declares size but leaves the SVG's literal width/height in place. In a design system you must bind :width="size" :height="size" in the template after generation. Bake this into your generation script so every icon gets the binding consistently.
Bundle ships every icon even when one is imported
Tree-shaking brokenThis happens with a default-export-of-an-object barrel or side-effectful index. Use individual named re-exports of each SFC's default export, and ensure sideEffects: false in the package.json, so bundlers can drop unused icons.
Two-tone / multi-colour icons lose their second colour
Watch closelycurrentColor folds every colour into one. For two-tone icons, use SVG CSS Variable Injector to map each distinct colour to its own CSS variable instead, then theme each token independently. Blanket currentColor would flatten them.
Accessibility attributes missing
Add manuallyThe converter emits no aria-hidden, role, or <title>. Decorative icons need aria-hidden="true"; meaningful icons need a <title> or aria-label. Add these in the SFC (or in a post-generation step) — there is no option for it.
Component names clash after kebab-casing
Overwrite riskVue kebab-cases the suggested filename. Two source files that normalise to the same name can overwrite each other in your output folder. Enforce unique, kebab-case source filenames before generating the set.
Figma metadata bloats the committed component
PreventableSkipping the scrub/minify steps means Figma IDs and metadata end up inside <template>, inflating every component and its diff. Always scrub and minify the source before wrapping — it's the cheapest size win in the pipeline.
Trying to convert the whole set in the UI at once
Single file onlyThe UI handles one SVG per run. For a real library, automate generation through the API/runner in a loop (see the batch guide). The browser tool is for spot-checks and one-offs.
SSR shows a flash before icons appear
Preserved (inlined)Because the SVG markup is inlined in the SFC, Nuxt renders it in the server HTML — no flash. If you do see one, you're likely loading icons via external <img src> somewhere; switch those call sites to the inlined components.
Frequently asked questions
Should Vue icon components use defineProps or the Options API?
Use defineProps in <script setup> — it's the Vue 3 standard with the best TypeScript inference, and it's exactly what this converter emits. There is no Options-API output option. For a design system, type the props (size?: number | string; color?: string) so consumers get autocomplete.
Why don't my generated icons resize when I pass size?
The converter declares the size prop but does not bind it — your SVG keeps its literal width/height. Edit each generated <template> to <svg :width="size" :height="size" ...>, or add that rewrite to your generation script so every icon in the library gets it.
How do I make icons respond to the theme colour without inline styles?
Convert the SVG's colours to currentColor first (via SVG to Tailwind currentColor mode), then drive color from CSS — a parent text-* class or a --icon-color variable recolours the icon. The converter won't do the currentColor conversion, so it's a required prep step.
How do I keep the icon library tree-shakeable?
Author a barrel index.ts of named re-exports (export { default as IconX } from './IconX.vue') and set "sideEffects": false in package.json. Then a consumer importing one icon ships only that icon. The converter outputs single SFCs, so the barrel is something you assemble around its output.
Can the converter handle a whole Figma export folder at once?
Not in the browser — it's single-file. For a full set, script the conversion through the API/runner in a loop, one file at a time (see the batch-generation guide). Clean each SVG first for small, consistent components.
Does the Vue component use scoped styles?
No — the converter emits no <style> block at all. Styling flows through the class prop (which Vue fallthrough applies to the root SVG) and CSS. That keeps icons easy to restyle from the outside without specificity battles. Add a scoped style block manually only if you have a specific need.
How do I handle two-tone or multi-colour brand icons?
Don't blanket-convert to currentColor (that flattens them). Use SVG CSS Variable Injector to map each distinct colour to its own CSS variable, then theme each variable independently. Wrap the result with the converter afterwards.
Can my Vue icon library be used outside Vue?
No — .vue SFCs need the Vue runtime. For a framework-agnostic library, generate web components (custom elements) from the same SVG source instead; for React specifically, use SVG to JSX, and for Svelte, generate Svelte output from this same tool.
How do I add accessibility to the generated icons?
Manually — the converter emits no ARIA. Mark decorative icons aria-hidden="true" and give meaningful icons a <title> or aria-label. The cleanest approach is a post-generation step that adds the right attribute based on whether the icon is decorative or semantic.
How do I version the icon library when icons change?
Semantic versioning: new icons are a minor bump, a breaking change to the shared prop interface (e.g. renaming size) is a major, and geometry fixes are patches. In a monorepo, changesets automate the bump and changelog when you regenerate components.
Will SSR avoid a flash of unstyled/missing icons?
Yes. Because the markup is inlined in each SFC, Nuxt 3 renders the SVG in the initial server HTML — no flash. External <img>-based SVGs are what cause the flash; the inlined components avoid it entirely.
Are my Figma exports uploaded when I generate components?
No. The browser conversion is local. If you automate the library build, generation runs on your machine via the paired @jadapps/runner — the JAD endpoint is upload-free and never receives your icon files. Unreleased brand marks stay private.
Privacy first
Every JAD SVG tool runs entirely in your browser using the DOM API and Canvas. Your SVG files never leave your device — verified by zero outbound network requests during processing.