Adaptive SVGs with CSS Custom Properties: Build Responsive Icons That Scale
You have 50 icons. They need to work in light mode, dark mode, three different sizes, and four brand colors. That's potentially 50 × 2 × 3 × 4 = 1,200 variations.
Or you could build adaptive SVGs that handle all variations with zero duplication.
This guide shows you how to use CSS custom properties with SVG <symbol> and <use> elements to create icon systems that adapt to any context—theme, size, color, or viewport—automatically.
The Problem with Traditional SVG Icons
Most icon implementations look like this:
<!-- Every icon is a full inline SVG -->
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="#6366f1" d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5..."/>
</svg>
<!-- Same icon, different color? Another full copy -->
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="#ef4444" d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5..."/>
</svg>
<!-- Different size? Another copy -->
<svg viewBox="0 0 24 24" width="48" height="48">
<path fill="#6366f1" d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5..."/>
</svg>
Problems:
- Bloated HTML with repeated path data
- Hardcoded colors can't adapt to themes
- Changes require updating every instance
- No single source of truth
The Adaptive SVG Solution
Here's the same scenario, built adaptively:
<!-- Define once -->
<svg style="display: none;">
<symbol id="icon-layers" viewBox="0 0 24 24">
<path fill="var(--icon-fill, currentColor)" d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5..."/>
</symbol>
</svg>
<!-- Use anywhere, customize with CSS -->
<svg class="icon icon-sm text-primary"><use href="#icon-layers"/></svg>
<svg class="icon icon-lg text-error"><use href="#icon-layers"/></svg>
.icon { --icon-fill: currentColor; }
.text-primary { color: #6366f1; }
.text-error { color: #ef4444; }
.icon-sm { width: 24px; height: 24px; }
.icon-lg { width: 48px; height: 48px; }
Benefits:
- Path data defined once, used everywhere
- Colors inherit from CSS
- Easy to theme
- Single source of truth
Understanding the Core Concepts
The <symbol> Element
A <symbol> is like a template—it defines graphics that aren't rendered directly but can be referenced:
<svg>
<symbol id="my-icon" viewBox="0 0 24 24">
<!-- This won't render on its own -->
<circle cx="12" cy="12" r="10"/>
</symbol>
</svg>
Key attributes:
id- Required for referencingviewBox- Defines the coordinate system
The <use> Element
<use> creates a reference to a symbol:
<svg width="24" height="24">
<use href="#my-icon"/>
</svg>
The symbol's content gets cloned into a Shadow DOM inside the <use> element. This is crucial for understanding how styling works.
CSS Custom Properties Cross the Shadow Boundary
Here's the magic: while regular CSS can't style elements inside the Shadow DOM, CSS custom properties can pass through:
<svg style="display: none;">
<symbol id="icon-star" viewBox="0 0 24 24">
<!-- Uses custom property with fallback -->
<path
fill="var(--icon-fill, gold)"
stroke="var(--icon-stroke, none)"
stroke-width="var(--icon-stroke-width, 0)"
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</symbol>
</svg>
<svg class="star-icon" width="24" height="24">
<use href="#icon-star"/>
</svg>
.star-icon {
--icon-fill: #f59e0b;
--icon-stroke: #d97706;
--icon-stroke-width: 1;
}
The <path> inside the symbol receives these custom property values.
Building an Adaptive Icon System
Let's build a complete icon system from scratch.
Step 1: Create the Symbol Sprite
<!-- icons.svg or inline at the top of your HTML -->
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="icon-home" viewBox="0 0 24 24">
<path
fill="var(--icon-fill, currentColor)"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24">
<circle
cx="11" cy="11" r="8"
fill="var(--icon-bg, none)"
stroke="var(--icon-stroke, currentColor)"
stroke-width="var(--icon-stroke-width, 2)"
/>
<path
stroke="var(--icon-stroke, currentColor)"
stroke-width="var(--icon-stroke-width, 2)"
stroke-linecap="round"
d="M21 21l-4.35-4.35"
/>
</symbol>
<symbol id="icon-user" viewBox="0 0 24 24">
<circle
cx="12" cy="8" r="4"
fill="var(--icon-fill, currentColor)"
/>
<path
fill="var(--icon-fill, currentColor)"
d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"
/>
</symbol>
<symbol id="icon-settings" viewBox="0 0 24 24">
<circle
cx="12" cy="12" r="3"
fill="var(--icon-bg, none)"
stroke="var(--icon-stroke, currentColor)"
stroke-width="var(--icon-stroke-width, 2)"
/>
<path
fill="none"
stroke="var(--icon-stroke, currentColor)"
stroke-width="var(--icon-stroke-width, 2)"
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"
/>
</symbol>
</svg>
Step 2: Define Base Icon Styles
/* Base icon class */
.icon {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
vertical-align: middle;
/* Default custom properties */
--icon-fill: currentColor;
--icon-stroke: currentColor;
--icon-stroke-width: 2;
--icon-bg: transparent;
}
/* Size variants */
.icon-xs { width: 1rem; height: 1rem; }
.icon-sm { width: 1.25rem; height: 1.25rem; }
.icon-md { width: 1.5rem; height: 1.5rem; }
.icon-lg { width: 2rem; height: 2rem; }
.icon-xl { width: 3rem; height: 3rem; }
Step 3: Create Theme-Aware Colors
:root {
--color-primary: #6366f1;
--color-secondary: #71717a;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-text: #18181b;
--color-text-muted: #71717a;
}
/* Dark mode overrides */
@media (prefers-color-scheme: dark) {
:root {
--color-text: #fafafa;
--color-text-muted: #a1a1aa;
}
}
/* Icon color utilities */
.icon-primary {
--icon-fill: var(--color-primary);
--icon-stroke: var(--color-primary);
}
.icon-secondary {
--icon-fill: var(--color-secondary);
--icon-stroke: var(--color-secondary);
}
.icon-success {
--icon-fill: var(--color-success);
--icon-stroke: var(--color-success);
}
.icon-warning {
--icon-fill: var(--color-warning);
--icon-stroke: var(--color-warning);
}
.icon-error {
--icon-fill: var(--color-error);
--icon-stroke: var(--color-error);
}
.icon-current {
--icon-fill: currentColor;
--icon-stroke: currentColor;
}
Step 4: Use the System
<!-- Basic usage -->
<svg class="icon"><use href="#icon-home"/></svg>
<!-- With size -->
<svg class="icon icon-lg"><use href="#icon-settings"/></svg>
<!-- With color -->
<svg class="icon icon-primary"><use href="#icon-user"/></svg>
<!-- Combined -->
<svg class="icon icon-xl icon-success"><use href="#icon-search"/></svg>
<!-- Inherits parent color -->
<a href="/profile" class="nav-link">
<svg class="icon icon-current"><use href="#icon-user"/></svg>
Profile
</a>
Advanced Techniques
Responsive Icons with Media Queries
Change icon complexity based on viewport:
<svg style="display: none;">
<!-- Simple version for small screens -->
<symbol id="icon-menu-simple" viewBox="0 0 24 24">
<path stroke="var(--icon-stroke, currentColor)" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</symbol>
<!-- Detailed version for large screens -->
<symbol id="icon-menu-detailed" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="2" rx="1" fill="var(--icon-fill, currentColor)"/>
<rect x="3" y="11" width="14" height="2" rx="1" fill="var(--icon-fill, currentColor)"/>
<rect x="3" y="17" width="10" height="2" rx="1" fill="var(--icon-fill, currentColor)"/>
</symbol>
</svg>
.icon-menu .simple { display: block; }
.icon-menu .detailed { display: none; }
@media (min-width: 768px) {
.icon-menu .simple { display: none; }
.icon-menu .detailed { display: block; }
}
<svg class="icon icon-menu">
<use class="simple" href="#icon-menu-simple"/>
<use class="detailed" href="#icon-menu-detailed"/>
</svg>
Multi-Color Icons
Some icons need multiple distinct colors:
<svg style="display: none;">
<symbol id="icon-notification" viewBox="0 0 24 24">
<!-- Bell body -->
<path
fill="var(--icon-fill, currentColor)"
d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"
/>
<!-- Bell clapper -->
<path
fill="var(--icon-fill, currentColor)"
d="M13.73 21a2 2 0 01-3.46 0"
/>
<!-- Notification dot -->
<circle
cx="18" cy="5" r="4"
fill="var(--icon-accent, #ef4444)"
/>
</symbol>
</svg>
.icon-notification-active {
--icon-fill: #18181b;
--icon-accent: #ef4444;
}
.icon-notification-muted {
--icon-fill: #71717a;
--icon-accent: #71717a;
}
Animated Adaptive Icons
Combine custom properties with CSS animations:
<svg style="display: none;">
<symbol id="icon-loading" viewBox="0 0 24 24">
<circle
cx="12" cy="12" r="10"
fill="none"
stroke="var(--spinner-track, #e5e5e5)"
stroke-width="var(--spinner-width, 2)"
/>
<circle
class="spinner-arc"
cx="12" cy="12" r="10"
fill="none"
stroke="var(--spinner-color, currentColor)"
stroke-width="var(--spinner-width, 2)"
stroke-linecap="round"
stroke-dasharray="40 60"
/>
</symbol>
</svg>
.icon-loading {
--spinner-track: #e5e5e5;
--spinner-color: #6366f1;
--spinner-width: 2;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.icon-loading {
--spinner-track: #3f3f46;
}
}
State-Based Variations
Toggle between icon states:
<svg style="display: none;">
<symbol id="icon-heart-outline" viewBox="0 0 24 24">
<path
fill="none"
stroke="var(--icon-stroke, currentColor)"
stroke-width="2"
d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"
/>
</symbol>
<symbol id="icon-heart-filled" viewBox="0 0 24 24">
<path
fill="var(--icon-fill, currentColor)"
d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"
/>
</symbol>
</svg>
<button class="like-button" aria-pressed="false">
<svg class="icon heart-icon">
<use class="outline" href="#icon-heart-outline"/>
<use class="filled" href="#icon-heart-filled"/>
</svg>
</button>
.heart-icon .filled {
opacity: 0;
transform: scale(0);
transition: opacity 0.2s, transform 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.heart-icon .outline {
transition: opacity 0.2s;
}
.like-button[aria-pressed="true"] .filled {
opacity: 1;
transform: scale(1);
--icon-fill: #ef4444;
}
.like-button[aria-pressed="true"] .outline {
opacity: 0;
}
Loading Symbols from External Files
For better caching and organization, store symbols in an external file:
<!-- In your HTML head -->
<link rel="preload" href="/icons.svg" as="image" type="image/svg+xml">
<!-- Using external symbols -->
<svg class="icon">
<use href="/icons.svg#icon-home"/>
</svg>
Important notes:
- External references don't work with
file://protocol (local files) - Some older browsers have CORS issues with external SVG references
- The external file must be served from the same domain (or with proper CORS headers)
Accessibility with Adaptive SVGs
Ensure your adaptive icons remain accessible:
<!-- Decorative icon (hidden from screen readers) -->
<svg class="icon" aria-hidden="true">
<use href="#icon-settings"/>
</svg>
<!-- Meaningful icon with accessible name -->
<svg class="icon" role="img" aria-label="Settings">
<use href="#icon-settings"/>
</svg>
<!-- Icon button -->
<button aria-label="Open settings">
<svg class="icon" aria-hidden="true">
<use href="#icon-settings"/>
</svg>
</button>
Learn more in our SVG Accessibility Guide.
Performance Considerations
Inline vs. External Sprites
| Approach | Pros | Cons | |----------|------|------| | Inline sprite | No extra HTTP request, always available | Increases HTML size, no caching | | External file | Cached separately, cleaner HTML | Extra request, CORS complexity |
Recommendation: For apps with many pages, use an external sprite. For single-page apps or landing pages, inline works fine.
Optimize Your Symbols
Before adding symbols to your sprite:
- Simplify paths - Reduce point count
- Remove metadata - Strip editor cruft
- Minify - Use SVG Minify to optimize
A bloated sprite defeats the purpose of the system.
Tools for Building Adaptive SVG Systems
Creation
- AI Icon Creator - Generate base icons
- SVG Editor - Prepare icons for the sprite system
- AI SVG Generator - Create custom graphics
Optimization
- SVG Minify - Compress sprite files
- SVGO - Command-line optimization
Testing
- SVG Playground - Test custom property inheritance
- Browser DevTools - Inspect computed styles
Key Takeaways
- Define once, use everywhere -
<symbol>and<use>eliminate duplication - CSS custom properties bridge the gap - They penetrate Shadow DOM where regular CSS can't
- Plan your property API - Consistent naming (
--icon-fill,--icon-stroke) makes the system predictable - Inherit from context -
currentColorlets icons adapt to their surroundings automatically - Think in systems - Define sizes, colors, and variants as utility classes
- Optimize the sprite - A bloated sprite defeats the purpose
Adaptive SVG systems take more upfront work than dropping in individual icons, but they pay dividends in maintainability, consistency, and flexibility as your project grows.
Related Articles: