"SVG is infinitely sharp" is only half true. The file is resolution-independent, but your screen isn't.
Every SVG is turned into pixels at the end, and when a shape edge falls partway through a pixel, the renderer shades that pixel with partial opacity. That's anti-aliasing.
A little of it makes curves look smooth. A lot of it along straight lines makes an icon look out of focus.
Below, I measure where the soft pixels come from and show how to get rid of them. The images and numbers come from a script that rasterizes the icons with librsvg (sharp 0.34.5) and, for the display-scaling test, with headless Chromium.
The mechanism: an experiment with one line
Take a 24×24 SVG with a single horizontal stroke and rasterize it at 24px, which is one SVG unit per pixel. I did this for four combinations of stroke width and position and read back the alpha value of each pixel row:
<!-- A 1-unit stroke centred on y=12 covers y=11.5 to 12.5:
half of row 11 and half of row 12, so both rows get 50% ink -->
<path d="M4 12H20" stroke="#000" stroke-width="1" />
<!-- Centred on y=12.5 it covers y=12 to 13: exactly one full row -->
<path d="M4 12.5H20" stroke="#000" stroke-width="1" />
- 1-unit stroke at y=12: rows 11 and 12 both come out at alpha 128 of 255. You wanted one crisp black line and got two gray ones.
- 1-unit stroke at y=12.5: row 12 at alpha 255, nothing else. Crisp.
- 2-unit stroke at y=12: rows 11 and 12 at alpha 255. Crisp.
- 2-unit stroke at y=12.5: rows 11 and 13 at 128, row 12 at 255. Soft on both sides.
The rule: a straight edge is sharp only when it lands exactly on a pixel boundary. Odd stroke widths need half-pixel coordinates, and even stroke widths need whole-pixel coordinates. So round an icon's size and position to whole pixels, but leave the coordinates inside it alone.
Icon designers build icons on a grid with these rules in mind. Anything that scales or shifts the icon after that can undo their work.
That includes optimization. Aggressive precision settings in tools like SVGO round coordinates, and rounding a point from 12.5 to 13 moves an edge off the pixel grid. If an icon looked crisp before optimization and soft after, raise the precision setting.
Measured: how display size changes sharpness
To see how much this matters in practice, I rasterized three real "home" icons at several sizes and counted the pixels with partial alpha (between 0 and 255) as a share of all visible pixels. A lower share means more solid pixels and a crisper look.

Curves always produce some partial pixels, so compare sizes within a row, not across icons:
- Lucide house (24-unit grid, 2-unit stroke): 16px 98.4% · 20px 73.7% · 24px 37.9% · 30px 50.4% · 36px 32.3% · 48px 22.3%.
- Tabler home (24-unit grid, 2-unit stroke): 16px 81.1% · 20px 77.4% · 24px 46.5% · 30px 48.4% · 36px 37.7% · 48px 29.2%.
- Heroicons home, outline (24-unit grid, 1.5-unit stroke): 16px 94.3% · 20px 90.1% · 24px 84.2% · 30px 71.8% · 36px 65.1% · 48px 51.5%.
Lucide's house nearly halves its soft pixels between 20px and 24px, even though 24px is only slightly bigger. At 24px every grid line lands on a pixel, while at 20px everything is scaled by 0.833 and a 2-unit stroke becomes 1.67 pixels.
At 16px, almost every pixel of a 2px-stroke outline icon is partial. The strokes are 1.33 pixels wide and can't be crisp.
Heroicons' 1.5-unit stroke is soft even at its native 24px, because a 1.5px line can never fill whole pixels. That's a design choice for a lighter look, not a bug, but it's why these icons look softer next to 2px sets.
These numbers come from librsvg. Browser rasterizers anti-alias somewhat differently, but not by much here: the same 24px Lucide house screenshotted in headless Chromium has 38.2% partial pixels against librsvg's 37.9%. The geometry that causes them is the same everywhere. Measured on lucide-react 0.460.0, the version Icony ships; newer releases add icons but keep the same drawing rules.
Step 0: confirm it is really an SVG
Before tuning anything, make sure you're looking at a vector. Zoom the browser to 400%: a true SVG stays clean at the new size, and a bitmap gets blockier.
Then check the file itself. Some tools export "SVG" files that are only a PNG wrapped in an <image> tag:
# Is the file really vector? Look for embedded bitmaps
grep -c "<image" icon.svg
grep -o "data:image/[a-z]*;base64" icon.svg | head -1
# Check the actual file type, whatever the extension says
file icon.svgIn DevTools, the Network panel's Content-Type column tells you the same thing for files served over HTTP. If it's a raster in disguise, go back to the source vector. No CSS will fix it.
Cause 1: a display size that is off the icon grid
This is the most common cause in real projects, and the measurements above show why. Sizes like 1.2em, 1.25rem, or 18px scale a 24-unit icon by a non-integer factor, so every carefully aligned edge moves onto a fractional pixel.
/* Before: 1.2em of a 15px font = 18px, i.e. 0.75 of the 24-unit grid */
.icon { width: 1.2em; height: 1.2em; }
/* After: a whole-pixel size that is a multiple of the grid */
.icon { width: 24px; height: 24px; }
/* Centering: avoid translate(-50%, -50%) on odd-sized boxes */
.icon-wrap { display: grid; place-items: center; width: 40px; height: 40px; }Is 18px always wrong, then? Not necessarily. For a 24-grid icon, 18px is a 0.75 scale, so a 2-unit stroke becomes 1.5px and whole-unit coordinates land on quarter pixels. It can look fine on high-density screens; on 1× screens, 16 or 20 is usually sharper.
If you need smaller icons, pick a set drawn for that size. Heroicons, for example, ships separate 20px and 16px solid sets alongside its 24px icons. Don't shrink a 24px outline icon and hope it holds up. Each library's native grid is listed in icon size and stroke width.
Cause 2: the stroke width scaled into fractions
Sometimes the design really does call for 20px. Then keep the stroke a whole number of screen pixels, even if its value in SVG units becomes fractional. Lucide's absoluteStrokeWidth does this for you (it writes 2.4 at 20px, which renders as 2px), and vector-effect: non-scaling-stroke does it for any inline SVG. Both are shown side by side in icon size and stroke width.
Tools that let you change stroke weight directly, like the stroke slider in Icony, let you do the same thing by hand. At small sizes, a slightly heavier stroke often looks sharper than the default.
Cause 3: fractional positioning
A perfectly sized icon still blurs if its top-left corner lands at x = 10.5. Common ways this happens:
transform: translate(-50%, -50%)on an icon or wrapper whose width or height is odd, so half of it is a fraction.- Centering a 24px icon inside a 33px or 41px box, which leaves 4.5px on each side.
- Text-relative layout: icons aligned to a baseline,
vertical-align: middle, or line heights like1.5on a 15px font (22.5px lines). - Percentage widths in fluid layouts, where a column of
33.333%puts everything after it on a fraction.
Rather than guess, paste this into the DevTools console. It lists every SVG whose position or size doesn't fall on whole device pixels:
// Paste into the DevTools console: lists SVGs that don't land on whole device pixels
const dpr = window.devicePixelRatio;
for (const svg of document.querySelectorAll('svg')) {
const r = svg.getBoundingClientRect();
const frac = [r.left, r.top, r.width, r.height]
.map((v) => Math.abs(v * dpr - Math.round(v * dpr)));
if (frac.some((f) => f > 0.01)) console.log(svg, r.left, r.top, r.width, r.height);
}Fix what it finds with even-sized containers, grid or flex centering instead of percentage transforms, and explicit pixel sizes on the icon wrapper.
Cause 4: fractional display scaling
On a Windows laptop at 125% or 150% scaling, or in a browser zoomed to 110%, one CSS pixel isn't a whole number of device pixels. A 24px icon becomes 30 or 36 device pixels, and a 2px stroke becomes 2.5 or 3.

House, 24 CSS px, stroke 2, color #111827, as inline SVG in headless Chromium (Playwright 1.64.1) at devicePixelRatio 1, 1.25 and 2. Each element screenshot is enlarged with nearest-neighbor scaling to 240px wide. Partial = pixels with 0 < alpha < 255, as a share of pixels with alpha > 0.I measured it in Chromium: the same icon has 38.2% partial pixels at a device pixel ratio of 1, 49.3% at 1.25, and 20.2% at 2. At 1.25 the 2px strokes become 2.5 device pixels, and there's no way to avoid that.
This is also why icons can look fine on your Mac and fuzzy on a colleague's PC. On a 2× screen each CSS pixel is a 2×2 block of device pixels, which hides most anti-aliasing, while on a 1× or 1.25× screen the same partial pixels are big enough to see. Check icons at 1×, for example on an external monitor or with DevTools device emulation.
You can't control the user's scaling, so accept some softness here rather than bending your design around one scaling factor.
Cause 5: transforms and animations
transform: scale(1.1) on hover, a zoom-in entrance animation, or a parent with a non-integer scale() all change the icon's pixel size after layout. While a transform animates, browsers may draw the element once and scale that bitmap, so it can look soft mid-animation and snap back when the transition ends.
If an icon stays blurry at rest, check its computed transform and those of its ancestors in DevTools for a leftover scale or translate.
Cause 6: a missing or mismatched viewBox
Without a viewBox, an SVG has no internal coordinate system to scale. Change its width and height and the drawing stays the same size while its canvas changes, so it gets cropped or padded instead of resized.
If the viewBox is present but has a different aspect ratio from the box you display it in, preserveAspectRatio centers the drawing, which can offset it by half a pixel. Keep viewBox="0 0 24 24" (or whatever grid the icon was drawn on) and display it in a box with the same ratio.
Cause 7: a PNG exported at 1×
If you had to use a PNG (for email, an app, a CMS that rejects SVG), the image is fixed at the pixel count you exported. A 24px PNG on a 2× screen is stretched to 48 device pixels and looks soft. Export at the display size × 2 (× 3 for phones) and set the display size with width and height.
A "fix" that makes things worse
shape-rendering="crispEdges" turns off anti-aliasing. Straight horizontal and vertical lines get hard edges, but every curve and diagonal turns jagged, and strokes on fractional coordinates can jump by a pixel or vanish.
It's useful for pixel-art-style graphics and grid lines, not for general icons. Fix the geometry instead.
The checklist
A blurry SVG is almost never the format's fault. It's geometry that ended up between pixels, and these checks find where:
- Zoom to 400% and inspect the file. Is it a real vector?
- Is the rendered size a whole multiple of the icon's grid (24, 48 for 24-unit icons)?
- If not, is there a set drawn for that size, or can you keep the stroke at whole screen pixels (
absoluteStrokeWidth,vector-effect)? - Does the console snippet flag fractional positions? Fix odd containers and percentage transforms.
- Is there a leftover
transform: scale()on the icon or an ancestor? - Does the root
<svg>have aviewBoxthat matches the display ratio? - If it's a PNG, was it exported at 2× or 3× the display size?
- Did it only get soft after optimization? Raise the coordinate precision.