SVG ID collisions: why gradients disappear when icons repeat
Why inline SVG icons with gradients, clip paths or masks break when they repeat on a page, how to find duplicate IDs, and four ways to fix them.
5 min read
The bug report usually sounds like this: "the third token logo in the list shows the wrong colors," or "the gradient on our logo disappears when the mobile menu is closed." Nothing is wrong with the artwork. Two inline SVGs on the same page are using the same ID, and one of them is borrowing, or losing, the other's gradient.
This only affects SVG markup placed directly in the HTML. Once you know the mechanism, the fixes are straightforward.
One page, one set of IDs
Gradients, clip paths, masks, filters and patterns are defined once inside an SVG and then referenced by ID:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#a1a1aa"/>
<stop offset="1" stop-color="#3f3f46"/>
</linearGradient>
</defs>
<circle cx="12" cy="12" r="12" fill="url(#a)"/>
</svg>
When this file is shown through <img>, it is its own document and #a can only mean its own gradient. When the same markup is pasted inline into an HTML page, it joins the page's document. IDs in HTML are document-wide, and url(#a) resolves to the first element in the whole page with id="a", wherever that is.
Short IDs make this worse. Optimizers commonly rename IDs to a, b, c to save bytes, so in a library of optimized logos, many files define id="a".
The three symptoms
Wrong colors. Two different logos both define id="a". The second logo's fill="url(#a)" resolves to the first logo's gradient, so it paints with someone else's brand colors. The first logo looks fine, which makes the bug look like a problem with the second file.
A gradient that vanishes. The same icon appears twice: once in a menu hidden with display: none and once in the visible page. Both copies point to the first definition, which lives inside the hidden menu. In several browsers, a gradient inside a display: none subtree does not render, so the visible copy loses its fill. It comes back the moment you open the menu, which is a strong clue.
Wrong clipping or masking. The same mechanism applies to clipPath and mask. A shape that should be cropped to a circle shows its square corners, or gets cropped by another icon's clip path.
Duplicate IDs also make the HTML invalid and confuse anything else that relies on IDs, such as aria-labelledby pointing at an SVG <title>.
Finding duplicates
Paste this into the browser console on the affected page:
const seen = new Set()
const dupes = new Set()
for (const el of document.querySelectorAll('[id]')) {
if (seen.has(el.id)) dupes.add(el.id)
seen.add(el.id)
}
console.log([...dupes])
If the list includes short names like a and b, or names starting with clip0 or paint0_linear, you have found the cause. The last two are typical of design tool exports, which number IDs per file, not per page.
Fix 1: generate unique IDs at render time
If your icons are components, generate the IDs per instance. In React, useId gives every rendered component a unique string that is stable between server and client rendering:
import { useId } from 'react'
export function GradientCoin({ size = 24 }: { size?: number }) {
const uid = useId()
const fillId = `${uid}-fill`
const clipId = `${uid}-clip`
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
<defs>
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#a1a1aa" />
<stop offset="1" stopColor="#3f3f46" />
</linearGradient>
<clipPath id={clipId}>
<circle cx="12" cy="12" r="12" />
</clipPath>
</defs>
<g clipPath={`url(#${clipId})`}>
<rect width="24" height="24" fill={`url(#${fillId})`} />
<path d="M12 4l6 8-6 8-6-8z" fill="#fafafa" />
</g>
</svg>
)
}
Call useId once and derive every ID in the component from it. The exact format of the returned string has changed between React versions, so treat it as opaque, and escape it if you ever use it in a CSS selector. This is the only fix that also handles the same icon appearing several times, because each instance gets its own IDs. Using SVG icons in React covers turning SVG files into components in the first place.
Fix 2: prefix IDs at build time
If you ship SVG files and inline them from a build step or a template, rewrite their IDs once, per file, during the build. SVGO's prefixIds plugin does this: it adds a file-specific prefix to every ID and updates each url(#...) and href="#..." that points at it. See SVGO settings that are safe for icons for a configuration.
Build-time prefixes make different files stop colliding with each other. They do not help when the same file is inlined twice, since both copies get the same prefix. In practice that case is harmless as long as the first copy is never hidden with display: none.
Fix 3: use img instead of inline SVG
Each <img src="icon.svg"> is a separate document with its own ID namespace, so collisions are impossible. Images are also cached by URL and keep the DOM small, which helps in long token lists.
The trade-off is styling. Page CSS cannot reach inside an image, so you cannot change fills on hover or follow currentColor. For multi-color brand logos, that rarely matters: you are not supposed to recolor them anyway. For single-color icons you want to tint, a CSS mask gives you recoloring without inlining. The comparison in inline SVG vs img vs CSS background lays out the other differences.
Fix 4: define shared paint once
For your own design system icons, where several icons genuinely share the same gradient, define it once in a small SVG near the top of the page and reference it from every icon:
<svg width="0" height="0" style="position: absolute" aria-hidden="true" focusable="false">
<defs>
<linearGradient id="ds-accent" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e4e4e7"/>
<stop offset="1" stop-color="#71717a"/>
</linearGradient>
</defs>
</svg>
Note the zero size and absolute positioning instead of display: none. The holder takes up no space but stays rendered, so the gradient keeps working. This approach suits a closed set you control. It does not suit third-party logos, which each have their own paint.
Which fix to choose
| Situation | Fix |
|---|---|
| Icon components rendered many times | Unique IDs per instance (useId) |
| A library of optimized files inlined by a build step | Prefix IDs per file at build time |
| Multi-color brand logos in lists and tables | <img>, which avoids the problem entirely |
| Your own icons sharing one gradient | Shared definitions in an always-rendered holder |
Whichever you pick, rerun the console snippet afterwards. An empty array means every ID on the page is unique. For more on how gradients and clip paths are defined and referenced, see gradients, masks and clip paths in icon SVGs.