When an SVG icon refuses to change color, the CSS is usually fine. What decides which approach works are two things you can't see from the outside: where the icon keeps its paint (a fill or a stroke, on the root <svg> or on each shape) and how the icon got onto the page (inline markup, an <img> tag, a CSS background, or a sprite reference).
I'll answer those two questions first, then go through each technique and the traps that make it look like nothing happened.
Step 1: look at where the paint lives
Open the icon in your editor or in the browser's element inspector before writing any CSS. The snippet below is trimmed from what four popular React icon packages actually render (I rendered each with react-dom/server at the versions noted). The Lucide markup comes from lucide-react 0.460.0, the version Icony ships; newer releases add icons but keep the same drawing rules. They look alike, but they don't recolor the same way:
<!-- Lucide (lucide-react 0.460): paint on the <svg>, stroke only -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" …>
<path d="M20 6 9 17l-5-5"></path>
</svg>
<!-- Heroicons solid (2.2.0): paint on the <svg>, fill only -->
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" …>
<path fill-rule="evenodd" d="…"></path>
</svg>
<!-- Radix (1.3.2): the root says fill="none"; the PATH carries the color -->
<svg viewBox="0 0 15 15" fill="none" …>
<path d="…" fill="currentColor" fill-rule="evenodd"></path>
</svg>
<!-- Phosphor (2.1.10): every weight, even "thin", is a filled shape -->
<svg viewBox="0 0 256 256" fill="currentColor" …>
<path d="…"></path>
</svg>- Lucide, Tabler outline, Heroicons outline draw with strokes.
fillisnoneand the color is instroke. Afillrule doesn't recolor the line; it paints the inside of any closed shape, which is why Lucide's star fills in blue below. - Heroicons solid, Bootstrap Icons, Tabler filled are fills on the root element.
fillworks.strokedoesn't recolor them either: it adds an outline around the shape, one viewBox unit wide by default. - Radix Icons set
fill="none"on the<svg>and putfill="currentColor"on the<path>, so afillon the root changes nothing. Keep that in mind for later. - Phosphor is filled at every weight. Its “thin” and “light” styles look like line icons but are outlined shapes. A
strokerule adds a one-unit outline on a 256-unit grid, which at 48px is 0.188px wide and doesn't show.
To check, I rendered each package's star component with react-dom/server, put it in a gray (#6b7280) parent, and applied three rules to the root <svg>:

<svg>. Lucide 0.460.0, Heroicons 2.2.0, Radix 1.3.2, Phosphor 2.1.10.Only the color column turns all four blue. All of these packages paint with currentColor, so the first method below works on every one of them without you knowing the details.
Method 1: set color and let currentColor do the work
currentColor is a CSS keyword that resolves to the element's computed color value. An inline SVG whose paint is currentColor inherits the text color of its parent, like a glyph in a font. You never style the icon directly; you style the text around it:
/* The icon inherits the text color of whatever it sits in */
.btn { color: #1f2937; }
.btn:hover { color: #2563eb; } /* icon and label change together */
.btn-danger { color: #b91c1c; }
/* Dark mode comes for free: flip the text color, icons follow */
@media (prefers-color-scheme: dark) {
.btn { color: #e5e7eb; }
}Make this your default. Hover, focus, disabled, and dark-mode states are already defined for your text, and the icons pick them up with no extra rules.
Component libraries in React work the same way. <Check className="text-red-600" /> works in Tailwind because the class sets color and the icon's stroke is currentColor.
currentColor only works when the SVG is part of the DOM: inline markup, a React/Vue component, or a <use> reference. An <img src="icon.svg"> is a separate document and never sees your page's color. For that case, use the mask technique below.
Method 2: set fill or stroke directly
Use color when the icon should match the surrounding text, which is the usual case. Sometimes you want an icon to have its own color independent of the text, for example a green check inside a gray sentence. Then you set the paint property itself, and you have to pick the right one:
/* Stroke-based sets (Lucide, Tabler outline, Heroicons outline) */
.icon-outline { stroke: #2563eb; }
/* Fill-based sets (Heroicons solid, Phosphor, Bootstrap) */
.icon-solid { fill: #2563eb; }
/* Radix and other icons that put fill on the <path>:
styling the <svg> is not enough, target the shape itself */
.icon-radix path { fill: #2563eb; }Two rules explain almost every “my CSS is ignored” report:
- Presentation attributes lose to CSS. An attribute such as
fill="#000"counts as the lowest-priority author style, so any stylesheet rule that matches the element wins, even a plainsvg { fill: red }. An inlinestyle="fill: #000"is different: it beats your stylesheet unless you use!important. - Inheritance loses to anything set on the element itself. Setting
fillon the<svg>only reaches children that don't declare their ownfill. Radix paths declarefill="currentColor", so.icon { fill: blue }on the root has no visible effect, while.icon { color: blue }or.icon path { fill: blue }works.
The Tabler bounding-box trap
The raw SVG files shipped in the @tabler/icons package (as opposed to the React components, which omit it) start with an invisible path that spans the full 24×24 canvas. It's there so design tools keep the artboard size, and it causes no trouble until you write a broad selector:
<!-- A raw file from @tabler/icons (icons/outline/check.svg) -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" …>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> <!-- invisible 24×24 box -->
<path d="M5 12l5 5l10 -10" />
</svg>
/* This rule overrides fill="none" on BOTH paths:
the invisible bounding box turns into a solid blue square */
.icon path { fill: #2563eb; }
/* Safer: skip the helper path, which is the one with stroke="none" */
.icon path:not([stroke="none"]) { stroke: #2563eb; }
check.svg from @tabler/icons 3.36.1, inlined unchanged at 96 CSS px in headless Chromium. Under .icon path { fill }, 91.5% of the icon's 24×24 box turns blue; under the :not([stroke="none"]) rule, 8.0% (the check itself).stroke has the same problem: a rule like .icon path { stroke: red } overrides stroke="none" and draws a square border around the icon. If you copy raw files from any library, check for helper shapes like this or scope your selectors to shapes that are actually painted.
Method 3: change the color in the file
Icons exported from Figma, Illustrator, or stock sites usually have colors written into every shape (fill="#000000"). CSS can still override attributes, but when the file is used through <img>, or when you want the icon to follow currentColor everywhere, it's cleaner to fix the source.
For a single file, find and replace the hex value. For a folder, a one-line sed does it:
# Replace a hard-coded black with currentColor in every SVG in a folder
# macOS (BSD sed needs the empty '' after -i):
sed -i '' 's/#000000/currentColor/g; s/#000"/currentColor"/g' icons/*.svg
# Linux (GNU sed):
sed -i 's/#000000/currentColor/g; s/#000"/currentColor"/g' icons/*.svg
# Check what is left before you commit
grep -l 'fill="#' icons/*.svgLook before you replace. Multi-color icons (flags, brand logos, illustrations) use different colors on purpose, and replacing all of them with currentColor flattens the artwork into a single-color silhouette.
Also watch for fill="none" and fill="white" used as cut-outs. A white shape inside a dark one often acts as negative space and should stay as it is, or become transparent if the background must show through.
Icons you can only reference by URL
An SVG in <img>, in background-image, or in a CSS content: url() is rendered as an isolated image. No page CSS reaches inside it, so neither color nor fill works. You have three options:
- Inline it (or import it as a component with SVGR). It's the most flexible option and the only one that lets you style individual shapes.
- Use it as a CSS mask. The SVG only supplies the shape; the element's
background-colorsupplies the color. Set that tocurrentColorand the icon follows the text color again:
/* Recolor an SVG you can only reference by URL (img-style usage) */
.icon-mask {
width: 24px;
height: 24px;
background-color: currentColor; /* the color you actually see */
-webkit-mask: url(/icons/check.svg) center / contain no-repeat;
mask: url(/icons/check.svg) center / contain no-repeat;
}
<!-- markup: an empty element, labelled if it carries meaning -->
<span class="icon-mask" aria-hidden="true"></span>- Ship one file per color. Do this for email templates, favicons, and other places that can't run CSS masks or inline SVG. Generate the variants rather than hand-editing them.
A mask renders the silhouette in one flat color, so a multi-color SVG loses its internal colors. Masks also hide the image from assistive technology, so if the icon means something, label the element (see accessible SVG icons).
CSS filter can tint an <img> too, but a filter chain only approximates a target hex and is hard to maintain. Prefer the mask.
Two colors: duotone icons and sprites
Phosphor's duotone weight renders two paths: the main shape and a background shape marked opacity="0.2". Both use currentColor, which is why duotone icons come out as one hue at two strengths. To make the accent a different color, target the background layer by its attribute:
/* Phosphor duotone renders two paths; the back one has opacity="0.2" */
.icon-duo { color: #4f46e5; } /* main outline */
.icon-duo path[opacity] {
fill: #f59e0b; /* accent fill */
opacity: 1; /* drop the built-in 20% */
}Sprites (<symbol> + <use>) add a wrinkle: the shapes are cloned into a shadow tree, so selectors in your stylesheet can't reach individual paths. Inherited properties still flow in, including color and CSS custom properties. Build the symbol around those two hooks:
<!-- In the sprite: leave the base color to currentColor,
expose the accent through a custom property -->
<symbol id="icon-badge" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="currentColor" />
<path d="M8 12l3 3 5-6" fill="none" stroke-width="2"
style="stroke: var(--icon-accent, white)" />
</symbol>
<!-- At the call site: inherited properties reach inside <use> -->
<svg class="icon" style="color: #16a34a; --icon-accent: #fff">
<use href="#icon-badge" />
</svg>Pick a color that stays visible
Icons that carry meaning or act as buttons need at least 3:1 contrast against their background (WCAG 1.4.11). Bright palette colors such as green #10B981 and yellow #F59E0B fall short on white; the ratios for all eight of Icony's preset colors are in accessible SVG icons.
The usual fix is a darker shade of the same hue for light themes (and a lighter shade for dark themes). That's one more reason to drive icon color through color and a theme variable instead of a hard-coded fill.
Recolored exports and copied code
Visual tools that recolor icons for you have to write the color somewhere, and that usually means a hex value in an attribute. Icony's exporter, for example, renders each library's own React component with your chosen color. A Lucide or Tabler export comes out with stroke="#EF4444" on the root, Phosphor and Bootstrap get fill="#EF4444", Radix gets it on the path, and Heroicons get a color="#EF4444" attribute that their currentColor paint picks up.
That's what you want for a PNG, a slide, or a design handoff. If you paste the SVG or the generated React component into an app that has themes, replace the hex with currentColor once, and the icon follows your CSS from then on.
Troubleshooting: the color won't change
- Is the SVG inline? If it's an
<img>or a background, page CSS can't reach it. Inline it or use a mask. - Are you setting the right property? Outline icons need
strokeorcolor; filled icons needfillorcolor. - Is the paint on the child shapes? Target
svg path(andcircle,rect,linefor Lucide, which uses all of them) instead of the root. - Is there an inline
styleattribute? It beats your stylesheet; remove it or use!importantas a last resort. - Did the whole square fill in? You hit a helper path such as Tabler's bounding box. Narrow the selector.
- Does the color look washed out? Check for an
opacityorfill-opacityattribute on the shape. - Is it only black when you open the file on its own? With no parent text color,
currentColorfalls back to the default, which is black. That's expected and doesn't affect the icon on your page.
Inline the icon, make sure its paint is currentColor, and set color. Almost every other technique is a fallback for when one of those three conditions can't be met.