Executive Summary & Architecture Matrix: In modern web development, Vite has emerged as the standard frontend bundler. However, handling SVG icons often creates unexpected roadblocks because Vite treats SVGs as static asset URLs by default, breaking dynamic CSS styling and component-level props.
- Default Asset Import (
import icon from './icon.svg'): Returns a cached URL string. Best for decorative banners and static images rendered via<img>, but cannot be colored via CSS. - Raw Content Import (
import svg from './icon.svg?raw'): Bundles the SVG markup directly as a string for injection without external plugins. - Component Transformation (
vite-plugin-svgrfor React,vite-svg-loaderfor Vue): Transforms SVGs into JSX/TSX or Vue SFCs with fullcurrentColorand prop support. - Universal Scalable Pipeline (
unplugin-icons): Delivers zero-config, on-demand tree-shaking across 100+ open-source icon sets with zero build-time bloat.
1. Why Vite Handles SVGs Differently Than Webpack
Developers transitioning from Webpack-based boilerplates (such as Create React App) frequently expect import { ReactComponent as Icon } from './icon.svg' to work out of the box. In Create React App, Webpack bundled @svgr/webpack behind the scenes.
Vite is built on native ES modules (ESM) and Rollup. In Vite, importing any file with an .svg extension defaults to an asset URL reference:
// App.tsx
import viteLogo from '/vite.svg'; // returns "/vite.svg" or "/assets/vite.abc123.svg"
export function Header() {
return <img src={viteLogo} alt="Vite Logo" className="w-6 h-6" />;
}
While this is optimal for large raster images or complex illustrations, it creates three major limitations for UI icons:
- No CSS Color Synchronization: Because the SVG renders inside an isolated
<img>context, CSS properties likefill,stroke, andcurrentColorcannot penetrate the boundary. Hover color changes become impossible without CSS filter hacks. - Extra Network Handshakes: If icons are not inlined or cached aggressively, rendering dozens of small
<img src="...">icons creates multiple HTTP requests or image decode overhead. - TypeScript Declaration Errors: Out of the box, TypeScript does not recognize
.svgimports as React components or raw strings, throwingCannot find module './icon.svg' or its corresponding type declarations. (TS2307).
2. Native Asset Imports: ?url vs ?raw vs ?inline
Vite provides built-in query suffixes that give you granular control over asset loading without installing third-party plugins. Understanding these query parameters is fundamental to mastering Vite's pipeline.
The Standard URL Query (?url)
Explicitly signals to Vite that you want the hashed asset URL. Vite emits the file into your production build directory (dist/assets/) and returns its public URL path:
import searchIconUrl from './assets/search.svg?url';
console.log(searchIconUrl); // Output in prod: "/assets/search.d3e4f5.svg"
The Raw String Query (?raw)
When you append ?raw, Vite loads the raw text content of the SVG file directly into your JavaScript bundle as an exported string. This allows client-side injection without external compiler plugins:
// Vanilla JS / React / Svelte
import heartSvgRaw from './assets/heart.svg?raw';
export function HeartIcon() {
return (
<span
className="inline-flex text-rose-500 hover:text-rose-600 transition-colors"
dangerouslySetInnerHTML={{ __html: heartSvgRaw }}
/>
);
}
Using dangerouslySetInnerHTML or Vue's v-html with ?raw can expose your application to Cross-Site Scripting (XSS) if you ever import user-supplied SVGs or unverified third-party assets containing <script> tags or inline event handlers. When using trusted open-source icons from IconStash (all SVGO sanitized), raw imports are safe, but sanitization via DOMPurify is recommended for dynamic inputs.
3. React: Full Component Integration with vite-plugin-svgr
For React projects, the premier industry solution is vite-plugin-svgr. It leverages @svgr/core to compile SVGs into real React functional components at build time, granting direct access to SVG props (className, style, onClick, fill, strokeWidth).
Step 1: Installation
npm install -D vite-plugin-svgr
Step 2: Vite Configuration (vite.config.ts)
In your vite.config.ts, register the plugin and specify your import query preferences:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
export default defineConfig({
plugins: [
react(),
svgr({
// Configure SVGR options
svgrOptions: {
icon: true, // Replace hardcoded width/height with 1em for responsive scaling
svgo: true, // Run SVGO optimization automatically
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false, // Essential: preserve viewBox for proper scaling!
},
},
},
],
},
},
// Restrict component transformation to files imported with '?react'
include: '**/*.svg?react',
}),
],
});
Step 3: TypeScript Declaration (src/vite-env.d.ts)
To eliminate TypeScript compiler warnings and enable autocomplete, add the client types reference to your src/vite-env.d.ts file:
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
If you prefer explicit typings for ?react imports, you can specify:
declare module '*.svg?react' {
import React from 'react';
const SVG: React.FC<React.SVGProps<SVGSVGElement> & { title?: string }>;
export default SVG;
}
Step 4: Usage in Components
import React from 'react';
import CheckCircleIcon from './icons/check-circle.svg?react';
export function StatusCard({ isCompleted }: { isCompleted: boolean }) {
return (
<div className="flex items-center gap-3 p-4 bg-zinc-900 border border-zinc-800 rounded-lg">
<CheckCircleIcon
className={`w-5 h-5 transition-colors ${
isCompleted ? 'text-lime-400' : 'text-zinc-600'
}`}
strokeWidth={2}
/>
<span className="text-zinc-200 text-sm font-medium">
{isCompleted ? 'Verification Complete' : 'Pending Verification'}
</span>
</div>
);
}
4. Vue 3: Dynamic Icons with vite-svg-loader
If you are building with Vue 3 or Nuxt 3 on Vite, the equivalent high-performance solution is vite-svg-loader. It compiles SVG files into Vue Single File Components (SFCs) on the fly.
Step 1: Installation
npm install -D vite-svg-loader
Step 2: Configuration (vite.config.js / vite.config.ts)
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import svgLoader from 'vite-svg-loader';
export default defineConfig({
plugins: [
vue(),
svgLoader({
svgoConfig: {
multipass: true,
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
},
},
},
],
},
defaultImport: 'url', // Default to 'url', require '?component' for Vue SFCs
}),
],
});
Step 3: Component Usage in Vue 3
With vite-svg-loader, you can import the same SVG file as a component, as a URL, or as raw code depending on your specific needs:
<script setup lang="ts">
// Import as an interactive Vue component
import BellIcon from '@/assets/icons/bell.svg?component';
// Or as a static URL string
import bellUrl from '@/assets/icons/bell.svg?url';
</script>
<template>
<button class="icon-button">
<!-- Renders inline SVG with full CSS styling and Vue props -->
<BellIcon class="w-6 h-6 text-zinc-400 hover:text-lime-400" />
<span class="sr-only">Notifications</span>
</button>
</template>
5. The Universal Solution: unplugin-icons
When building enterprise applications that require dozens or hundreds of icons across disparate sets (such as Lucide, Tabler, Heroicons, and Phosphor), converting every SVG individually creates enormous maintenance baggage.
The modern gold standard across Vite, Rollup, Webpack, Nuxt, and Svelte is unplugin-icons. It connects to open-source icon sets (via Iconify's datasets) and compiles only the icons you use directly into your framework components at build time.
Installation
npm install -D unplugin-icons @iconify/json
Configuration
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import Icons from 'unplugin-icons/vite';
export default defineConfig({
plugins: [
react(),
Icons({
compiler: 'jsx',
jsx: 'react',
autoInstall: true, // Automatically installs icon packs as you import them
scale: 1.2, // Default size scaling (1em * scale)
defaultClass: 'inline-block shrink-0',
}),
],
});
Importing Icons on Demand
You can now import icons from any popular open-source library simply using the virtual path convention ~icons/{collection}/{icon_name}:
import IconSearch from '~icons/lucide/search';
import IconBrandGithub from '~icons/tabler/brand-github';
import IconSparkles from '~icons/ph/sparkle';
export function Toolbar() {
return (
<div className="flex gap-4 items-center">
<IconSearch className="text-zinc-400 hover:text-lime-400 w-5 h-5 cursor-pointer" />
<IconBrandGithub className="text-zinc-400 hover:text-white w-5 h-5" />
<IconSparkles className="text-amber-400 w-5 h-5" />
</div>
);
}
Because unplugin-icons resolves icons at build time, it performs 100% dead-code elimination (tree-shaking). Only the exact SVG paths for search, brand-github, and sparkle enter your production bundle, saving megabytes compared to legacy icon font libraries.
6. Architectural Decision Matrix: Which Approach to Choose?
Each method carries trade-offs between setup complexity, bundle size, CSS styleability, and runtime performance. Use this decision matrix to pick the right strategy for your web application:
| Method | Bundle Size Impact | CSS Theming (currentColor) | TypeScript Support | Runtime Overhead | Best Use Case |
|---|---|---|---|---|---|
Default ?url |
Zero (External Asset) | No (Isolated in <img>) |
Native (String URL) | Zero JS / Image decode | Decorative hero illustrations, banners, non-interactive graphics |
Raw String ?raw |
String in JS bundle | Yes (via innerHTML) |
Native (String) | DOM parsing on mount | Zero-dependency lightweight utilities, micro-frontends |
vite-plugin-svgr |
JSX AST in JS bundle | Full (Native SVG props) | Requires declaration | React VDOM reconciliation | Interactive React UI components, design systems, buttons |
vite-svg-loader |
Vue AST in JS bundle | Full (Native Vue props) | Built-in | Vue VDOM reconciliation | Interactive Vue 3 / Nuxt applications |
unplugin-icons |
Tree-shaken JSX/SFC | Full (Native props) | Automatic (virtual) | Minimal compiled nodes | Multi-library icon systems, dashboards, rapid prototyping |
| SVG Symbol Sprite | Single cached file | CSS variables / currentColor | Type-safe ID helpers | Zero JS AST parsing | Enterprise applications with 100+ icons rendered repeatedly |
7. Production Performance & SVGO Optimization
When importing dozens of SVGs directly into your JavaScript bundle, SVGO optimization is critical. Raw SVGs exported from design software like Figma or Adobe Illustrator contain unneeded metadata, XML namespaces, hidden editor layers, and explicit hardcoded colors that break UI themes.
1. Always Strip Hardcoded Width and Height
Configure SVGO to remove width and height attributes while strictly preserving the viewBox. This allows the icon to scale fluidly according to parent CSS classes (such as w-5 h-5 or width: 1.25rem):
// svgo.config.js
module.exports = {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false, // NEVER remove viewBox in responsive UI
},
},
},
'removeDimensions', // Strips width and height attributes
],
};
2. Convert Hardcoded Fills and Strokes to currentColor
If your SVG contains fill="#000000" or stroke="#333333", it will ignore CSS color utility classes. Replace hardcoded hex codes with currentColor so the SVG automatically inherits the typography color of its parent button or text container:
<!-- Before: Hardcoded fill ignores CSS -->
<svg viewBox="0 0 24 24" fill="#1E293B">...</svg>
<!-- After: Responsive to parent typography color -->
<svg viewBox="0 0 24 24" fill="currentColor">...</svg>
Instead of manually cleaning and configuring SVGO plugins for every icon, you can search over 134,701 vector icons on IconStash. With one click, copy clean SVGO-optimized SVG markup or copy React JSX directly configured with currentColor, standard viewBox, and zero attribution licensing.
8. Frequently Asked Questions
Why does Vite import SVG as a URL string instead of a React component by default?
By default, Vite adheres to web-standard ES module semantics for static assets. When you run import icon from './icon.svg', Vite resolves the file to a static asset URL string pointing to the public output path. Transforming SVG markup into a JavaScript React or Vue component requires an AST compiler plugin such as vite-plugin-svgr or vite-svg-loader.
How do I fix "Cannot find module ./icon.svg or its corresponding type declarations" in TypeScript?
Add a declaration file in your project (such as src/vite-env.d.ts) declaring the SVG module format. If using vite-plugin-svgr with ?react queries, include /// <reference types="vite-plugin-svgr/client" />. For standard raw or component imports, declare declare module '*.svg?react' { import { FC, SVGProps } from 'react'; const content: FC<SVGProps<SVGSVGElement>>; export default content; }.
What is the difference between ?raw and ?url in Vite SVG imports?
Using ?url forces Vite to resolve the SVG as a URL string (e.g. /assets/icon.a1b2c3.svg) suitable for <img src={url}> or CSS background-image. Using ?raw tells Vite to read the file contents as a plain text utf-8 string containing raw SVG markup, which can be injected directly into the DOM.
Can I change the stroke and fill color of an SVG imported with an img tag in Vite?
No. When an SVG is rendered inside an HTML <img> tag, the browser executes it in an isolated browsing context where parent CSS rules and currentColor do not penetrate. To dynamically recolor SVGs via CSS, you must import the SVG as an inline component, inject it via raw markup, or use CSS mask-image.
What is the best way to handle hundreds of icons in a Vite enterprise project without bundle bloat?
Use unplugin-icons with unplugin-auto-import or compile icons into an external SVG <symbol> sprite sheet. Importing hundreds of individual SVGs as React components inflates your main JavaScript chunk with inline SVG paths. Sprites or on-demand tree-shaken unplugin loaders ensure icons are loaded asynchronously or cached via HTTP.
Does vite-plugin-svgr support SVGO optimization out of the box?
Yes. vite-plugin-svgr delegates to @svgr/core, which bundles SVGO by default. You can customize SVGO plugins (such as removing unwanted title tags, stripping hardcoded widths/heights, or preserving viewBox) inside your vite.config.ts svgrOptions object.