The Problem
My site uses Simple Icons CDN to render skill icons. Following the official docs, I used the dual-color URL format for dark mode support:
<img src="https://cdn.simpleicons.org/nextdotjs/000000/ffffff" />
Expected behavior: black icons in light mode (#000000), white icons in dark mode (#ffffff).
Dark mode worked fine. But when I manually switched the site back to light mode, the icons stayed white—nearly invisible on a light background.
Investigation
Step 1: Inspect the CDN Response
Using curl to check what the CDN actually returns:
curl -s "https://cdn.simpleicons.org/nextdotjs/000000/ffffff"
The SVG content:
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Next.js</title>
<style>path{fill:#000000} @media (prefers-color-scheme:dark){path{fill:#ffffff}}</style>
<path d="M18.665 21.978C16.758..." />
</svg>
Key finding: the CDN's dark mode support uses @media (prefers-color-scheme: dark).
Step 2: Compare with the Site's Theme Implementation
My site uses a custom dark mode implementation:
- User preference stored in
localStorage - Theme toggled via
document.documentElement.classList.add('dark') - CSS variables switched through the
.darkselector
SVGs loaded via <img> tags are isolated documents—they can't see the host page's CSS classes or DOM state.
Step 3: Identify the Conflict
The problem occurs when the OS color scheme and the site theme disagree:
| OS Theme | Site Theme | SVG Perceives | Actual Fill | Result |
|---|---|---|---|---|
| Dark | Light | prefers-color-scheme: dark | #ffffff | ❌ White icon on light bg |
| Dark | Dark | prefers-color-scheme: dark | #ffffff | ✅ Correct |
| Light | Light | prefers-color-scheme: light | #000000 | ✅ Correct |
| Light | Dark | prefers-color-scheme: light | #000000 | ❌ Black icon on dark bg |
Root cause: SVGs loaded as <img> are isolated documents that only sense the OS-level prefers-color-scheme, not the site's custom theme.
The Solution
Core Idea
Since the CDN's @media (prefers-color-scheme) mechanism is incompatible with custom themes, bypass it entirely—split the dual-color URL into single-color URLs and let the site code choose dynamically based on the current theme.
Implementing getThemedIconUrl
/**
* Transforms Simple Icons CDN URL based on site theme.
* CDN's dark mode uses @media (prefers-color-scheme) which
* cannot detect custom site themes. This function splits
* dual-color URLs into single-color URLs based on current theme.
*
* @param url - Original icon URL
* @param theme - Current site theme
* @returns Theme-appropriate single-color URL
*/
function getThemedIconUrl(url: string, theme: 'light' | 'dark'): string {
if (!url.includes('cdn.simpleicons.org')) return url;
try {
const urlObj = new URL(url);
const parts = urlObj.pathname.split('/').filter(Boolean);
if (parts.length < 3) return url;
const slug = parts[0];
const lightColor = parts[1];
const darkColor = parts[2];
const color = theme === 'dark' ? darkColor : lightColor;
urlObj.pathname = color === '_' ? `/${slug}` : `/${slug}/${color}`;
return urlObj.toString();
} catch {
return url;
}
}
URL Transformation Examples
| Original URL | Light Mode | Dark Mode |
|---|---|---|
/nextdotjs/000000/ffffff | /nextdotjs/000000 | /nextdotjs/ffffff |
/github/_/ffffff | /github (default brand color) | /github/ffffff |
/react/61dafb/ffffff | /react/61dafb | /react/ffffff |
The _ placeholder is Simple Icons CDN's convention for "use default brand color."
Usage in Components
function SkillCard({ skill, colorKey, index }: SkillCardProps) {
const { theme } = useTheme();
const hasCustomIcon = skill.icon && /^https?:\/\//.test(skill.icon);
const iconSrc = hasCustomIcon && skill.icon
? getThemedIconUrl(skill.icon, theme)
: undefined;
return (
<div className="...">
{iconSrc ? (
<img
key={iconSrc}
src={iconSrc}
alt={skill.name}
className="h-4 w-4 object-contain animate-icon-theme-fade"
/>
) : (
skill.name.charAt(0).toUpperCase()
)}
</div>
);
}
Note the key={iconSrc} prop—when the theme changes and the URL changes, React unmounts the old element and mounts a new one, triggering the fade-in animation.
Caching Analysis
You might worry: does every theme switch trigger a new CDN request? No. Since light and dark modes use different URLs, the browser maintains independent cache entries for each.
Verifying the CDN's cache headers:
curl -sI "https://cdn.simpleicons.org/nextdotjs/000000" | grep -i cache
Cache-Control: public, max-age=86400, s-maxage=31536000, stale-while-revalidate=604800
cf-cache-status: HIT
| Cache Parameter | Value | Meaning |
|---|---|---|
max-age | 86400 | Browser caches locally for 24 hours |
s-maxage | 31536000 | CDN edge nodes cache for 1 year |
stale-while-revalidate | 604800 | Serve stale content for 7 days while revalidating |
In practice: both color variants are fetched once on first load, then all subsequent switches hit the browser cache—zero latency, zero network overhead.
Fade Animation
The instant color switch on theme change felt jarring, so I added a 200ms fade-in animation for a smoother transition.
CSS definition:
@keyframes icon-theme-fade {
0% { opacity: 0; }
100% { opacity: 1; }
}
Registered in Tailwind v4's @theme inline:
@theme inline {
--animate-icon-theme-fade: icon-theme-fade 0.2s ease-in-out;
}
Use the animate-icon-theme-fade class in components. Combined with key={iconSrc}, every theme switch replays the animation.
Alternative Approaches Considered
| Approach | Pros | Cons |
|---|---|---|
| Dynamic URL (chosen) | Full control, cache-friendly, any color | Requires component changes |
CSS dark:invert | Zero code change, one CSS class | Only works for B/W icons; color icons get inverted |
CSS color-scheme property | Keeps CDN dual-color URL | Inconsistent browser support; unreliable inside <img> SVGs |
Inline SVG + currentColor | Most flexible | Requires local SVG storage; loses CDN convenience |
Summary
When your site uses a custom dark mode implementation (not prefers-color-scheme), Simple Icons CDN's dual-color URLs break due to SVG's isolated document behavior. The fix is to split dual-color URLs into single-color URLs and dynamically select based on the current theme, combined with the key prop to trigger React remounting and fade animations for a smooth theme-switching experience.
Key takeaways:
- SVGs inside
<img>tags are isolated documents—they can't sense the host page's CSS classes - Simple Icons CDN's dark mode relies on
prefers-color-scheme, which is incompatible with custom theme systems - Splitting into single-color URLs with dynamic selection is reliable and cache-friendly
- The
keyprop is the key technique for triggering React remount animations