For weeks, users on Mac and iOS were seeing a slightly fuzzier logo than every Chrome user. Nobody complained. That's the most dangerous kind of bug.
A brand logo rendered crisp in Chrome and Firefox but subtly blurry in Safari (desktop + iOS) for weeks. The cause was layered: Safari rasterizes <img>-loaded SVGs at the consumer's CSS box size (Layer 1, broken) and rasterizes SVG <filter> regions at viewBox resolution (Layer 3, also broken). Inline SVG renders correctly via the path engine (Layer 2, fine). The fix exits both broken layers: render the SVG inline via vite-svg-loader, and recreate the dual-tone shadow as CSS filter: drop-shadow(). ~20 lines of production code; the diagnostic story is the actual lesson.
This is a walkthrough of a real engineering investigation. By the end you'll have a working model of the browser SVG pipeline and a small toolkit of debugging patterns that apply far beyond logo blur.
Most front-end bugs scream. This one whispered. Chrome, Firefox, Edge — all fine. iOS users and Mac Safari users were the only ones seeing a fuzzy glyph in the navbar where everyone else saw a crisp logo.
The simulation above is exaggerated for emphasis — the real bug was subtle, just barely "off." Subtle enough that the design team's first response was "the logo looks fine to us", until the reporter clarified that the issue was specifically Safari on retina.
It's the kind of bug that lives in the gap between what your code says and what the browser actually paints. Your source SVG is mathematically perfect — it's a vector, infinite resolution. But somewhere between the file on disk and the photons hitting your eye, Safari was making a quiet decision that nobody asked it to make.
The metaphor: it's like a perfume. The molecules are identical, but the chemistry of your skin changes how it smells. Safari's rendering pipeline is the skin chemistry. Same SVG, different result.
If a UI bug only reproduces in one browser, the bug is almost certainly in the rendering pipeline, not in your code. Stop staring at the component. Start tracing the path from byte to pixel.
Your team reports a UI bug that only reproduces in Safari on retina displays — never in Chrome. Where do you start your investigation?
Browser rendering of an SVG isn't a single step. It's a multi-stage pipeline — and Safari has at least three distinct layers where rasterization can occur. Each is a candidate for "this is where my logo gets blurry." Debugging is layer-by-layer.
Think of it like a 3-stage espresso machine: beans go in, espresso comes out, and any of the three stages can grind your output too coarse. Pulling the lever harder doesn't help if the second stage is what's misbehaving.
<img> raster path
Inline SVG path engine
SVG <filter> internal raster
An earlier session had concluded: "Safari rasterizes <img>-loaded SVGs at the SVG's intrinsic dimensions." If that were true, bumping the SVG file's width and height attributes from 74 to 512 would force Safari to rasterize at 512×512 — a much higher resolution — and the logo would go crisp.
So we ran the experiment. Total time: 30 seconds.
If Safari really rasterizes at intrinsic dims, this hack should produce a noticeably crisper rendering. The viewBox stays at 0 0 74 74, so the visible content doesn't shift; we're only changing the file-level width/height that the previous theory said matters.
Zero visual change. Same blur, same pixelation. The hypothesis was falsified in seconds.
The actual mechanism: Safari rasterizes <img>-loaded SVGs at the consumer's CSS box size (52×52 in our case), not at the SVG's intrinsic dimensions. The 512 we wrote in the file was invisible to the layout engine.1
When a fix at one layer doesn't change the output, you're at the wrong layer. The diagnostic test isn't "did it work?" — it's "did anything change?" A no-op fix is the loudest possible signal that you've misidentified the problem.
Bumping the SVG's intrinsic width and height from 74 to 512 (with viewBox unchanged) had ZERO effect on what Safari rendered. Why?
Most front-end bugs aren't in your code. They're in the gap between what you wrote and what the browser actually paints. — the thesis of this post
The diagnosis revealed two separate broken layers. Inline-rendering the SVG fixes Layer 1. But it doesn't touch Layer 3 (the SVG <filter> internal rasterization) — that's still broken even when the surrounding SVG renders crisply.
So the fix is two steps. Each one a different key for a different door. The metaphor of "exit the rendering pipeline" was almost right; the precise version is "exit the broken layer."
Stop loading the SVG via <img> (which routes through Safari's image rasterizer). Render it inline as DOM, where the SVG path engine takes over. The path engine is DPI-aware — vector content stays crisp at any zoom or device pixel ratio.
We added vite-svg-loader as a build-time plugin. It transforms ?component imports into Vue components that render the SVG markup directly into the DOM.
We register a Vite plugin that runs at build time. It scans for ?component SVG imports and rewrites them as Vue components.
removeViewBox: false overrideBy default the plugin runs SVGO (an SVG optimizer) which has an aggressive plugin called removeViewBox. It strips the viewBox attribute when width/height are present.
That's a well-known footgun: any consumer that wants to render at a different size than the file's intrinsic dimensions will get content rendered at literal coordinates and clipped. We disable it here. (Module 4 covers this trap in detail — it bit us mid-fix.)
SiteLogo imports the SVG via ?component — the magic suffix that triggers vite-svg-loader. The result is a Vue component (LogoSvg) that renders the SVG markup inline into the DOM.
Two consumers (mobile + desktop nav) need the logo. Without a wrapper, each would have to know about the asset path and the component import. With it, they just say <SiteLogo :size="42" /> and forget the rest.
inheritAttrsBecause LogoSvg is the single root element and Vue 3's default is inheritAttrs: true, the parent's @click automatically attaches to the SVG root. No defineEmits needed.
Even with inline SVG rendering, the <filter> element still rasterizes through Safari's filter buffer at user-space (viewBox) resolution — not device-pixel resolution. So even on an otherwise crisp SVG, the filter output is fuzzy. The white glyph itself stays sharp; the dual-tone glow around it diffuses.
The fix: strip the <filter> element from the SVG file. Recreate the same dual-tone shadow as CSS filter: drop-shadow(). CSS filters are applied at the compositor layer — at device resolution. DPI-aware, just like the path engine.
The first drop-shadow nudges the logo up-and-left with one tint. The second nudges it down-and-right with another. Stacked, they produce the same dual-tone glow as the original SVG <filter> — but rendered at device resolution because CSS filters operate at the compositor layer.
drop-shadow and not box-shadow?drop-shadow follows the alpha silhouette of the element — including the SVG's actual rounded shape. box-shadow would follow the bounding box, producing a square halo around the SVG's transparent padding. Wrong primitive for vector-shaped elements.
An earlier attempt tried this exact approach with the old <img>-loaded SVG. It "failed" because the underlying image was already rasterized at low resolution — a CSS shadow on a fuzzy base is still fuzzy. Now it works because the base SVG is crisp via inline rendering.
A fix can be correct in cause but wrong for the substrate. When the substrate changes, re-evaluate fixes you previously rejected. The "failed Option B" became the correct second step — same recipe, different base.
We removed the SVG <filter> element and recreated the same dual-tone shadow as CSS drop-shadow(). The same approach was tried earlier and concluded not to work. Why does it succeed now?
Halfway through the fix, the inline SVG suddenly rendered with the visible card extending beyond its container — clipped on the right and bottom. The source SVG file clearly had viewBox="0 0 74 74". The DOM, inspected in WebKit Inspector, showed:
Our source had viewBox. The DOM didn't. Something between source and browser was stripping it. That something was SVGO, an SVG optimizer that vite-svg-loader runs by default.
SVGO ships with a plugin called removeViewBox. Its logic: "if width and height are present, viewBox is redundant — remove it." Sometimes true. In our case, dangerously false. The moment a consumer specifies different render dimensions than the SVG's intrinsic ones (which is exactly what we do — we render a 74-coord viewBox into a 52-px box), the missing viewBox makes content render at literal coordinates and overflow.
The metaphor: a photo lab technician who silently "improves" your photos before printing. Your originals look fine. The prints look weird. You can't figure out why until you watch the technician work.
removeViewBox: false opts out of the harmful default. The rest of SVGO's optimizations still run (whitespace, redundant attrs, etc.) — we only neutralize the one plugin that breaks our use case.
SVGO ships a "preset-default" plugin bundle. To customize one plugin's behavior, you pass overrides on that preset rather than defining your own plugin list. Less brittle when SVGO releases new defaults.
The repo has a separate vitest.config.mjs from vite.config.mjs. Tests don't inherit the build config — we had to register the same plugin (with the same SVGO override) in both. Easy to miss until tests fail with "cannot resolve ?component import."
Build-time tooling has invisible opinions. They won't show up in your source code, but they shape what reaches the browser. New debug heuristic: when source markup ≠ rendered DOM, suspect the build chain before suspecting a runtime bug.
The same logo, rendered two ways. Both SVGs are the same byte content. The only difference: one keeps its viewBox attribute, the other doesn't.
Content scales proportionally inside the 74-unit viewBox. Card sits centered with margin for the (now-removed) filter shadow.
Without a viewBox, the SVG container is only 52 units wide. The card renders at literal coordinates (11,11)→(63,63) — the right and bottom 11 units fall outside the container and clip.
This is the actual symptom seen in WebKit Inspector. The visible card extends past the SVG's bounds because the 74-unit content is being interpreted as 52-unit coordinates.
You're debugging a UI bug. You can clearly see viewBox="0 0 74 74" in the source SVG file, but Inspector shows the rendered <svg> in the DOM has no viewBox attribute. What's the most likely cause?
The actual fix is small. The reusable lessons are not. Here's what survives outside this specific bug, ready to be picked up next time you're staring at something weird.
SiteLogo.vue is 12 lines. It owns the asset, exposes a clean size prop, forwards class/@click via Vue's default inheritAttrs. Two consumers; one source of truth. Single-element root + no defineEmits = nothing to maintain.
When to use: any time an asset (icon, illustration, embed) appears in 2+ places.
Before building "the real fix," write a 30-second hack that should work if your hypothesis is right. The 512×512 width bump took half a minute and falsified an entire previous diagnosis.
When to use: any time you're tempted to commit to a fix based on reasoning alone.
SVG stdDeviation=4.05 is in user-space (viewBox) units. CSS blur(8px) is in render-space. With a 74:52 viewBox-to-render scale, those don't translate 1:1. Re-derive the conversion every time the substrate changes.
When to use: any visual recipe (shadows, blurs, scaling) that crosses unit systems.
An earlier framing was: "exit the pipeline." Refined: exit the broken layer. Pipelines have multiple rasterization layers; identifying which one is broken matters. CSS drop-shadow exits SVG-filter-buffer; inline SVG exits <img>-rasterizer. They're different exits.
When to use: any rendering bug where one fix didn't work and you're tempted to give up.
When what you wrote isn't what the browser shows, the gap is a build transform. SVGO, image pipelines, autoprefixers — they all silently rewrite output. Look at the build chain before staring at runtime code.
When to use: Inspector shows missing attributes you wrote, or different values than your source declares.
The wrapper hit 100% statement/branch/function coverage with 5 tests in ~30 lines of test code. Possible only because the component does ONE thing: forward props to an imported asset. Smart components rarely reach 80%; dumb wrappers hit 100% effortlessly.
When to use: consider extracting a wrapper for any component that's hard to test — the wrapper concentrates the testable surface.
If the brand logo ever looks blurry in Safari again, here's the diagnostic checklist in order. Each failure has a one-line cause and a one-line fix:
<img src="..."> instead of an inline <svg>, somebody has reverted to an image-tag path. Layer 1 is back. Restore the wrapper component usage in the consumers.<svg> still has a viewBox attribute. If it's missing, SVGO's removeViewBox plugin is no longer overridden — check that the svgoConfig.plugins override is still present in vite.config.mjs AND vitest.config.mjs.<filter> element inside the <svg>. If present, Layer 3 is back. The dual-tone shadow should live in the wrapper's scoped CSS as filter: drop-shadow(...), not in the SVG file.?component. If the import lost the query suffix, the SVG is being loaded as a static asset URL again rather than transformed by vite-svg-loader.The component is small enough that a regression is almost always visible within the first two minutes of inspection.
The shape of the work, in order. The diagnostic story made concrete:
The fix landed in 4 commits. Each one captures a specific decision or correction. Walking through them in order reveals the shape of the work.
You're adding a new HeaderIcon wrapper component, modeled on the pattern above. You want clicks to work and you want CSS classes from the consumer to merge with your component's own. What's the FIRST thing you'd add to the template?
?component query.vite-svg-loader runs by default. The removeViewBox plugin lives at plugins/removeViewBox.js and explains its own logic in the source.<feGaussianBlur> — what we removed from the SVG file. Behavior of stdDeviation and how SVG filter regions work.filter: drop-shadow() — what we replaced it with. Note the alpha-silhouette behavior that distinguishes it from box-shadow.<img>-loaded SVG rendering harmonization. Open since 2012.Most front-end bugs aren't in your code. They're in the gap between what you wrote and what the browser actually paints. SVG rasterization, build-time transforms, compositor layers — these are the places where the most interesting work hides, and most of us don't spend enough time there. The fix that shipped here is 20 lines. The understanding that produced it is the part worth keeping.