BuildNot yet confirmed elsewhere1 publisher3 min readPublished
The stroke width that never rendered: SVG attributes lose every cascade fight
A stylesheet rule beat a zoom-compensating presentation attribute for months, silently. The takeaway from one icon editor's postmortem: dynamic SVG styling belongs in custom properties.
The Engineer · Build desk
What happened
- A zoom-compensating stroke width written with setAttribute never rendered, because a stylesheet rule naming the same property outranks a presentation attribute.
- Chromium computed 0.35px from an attribute that asked for 0.05, and nothing in the toolchain raised an error for months.
- The rewrite publishes 1 / zoom as a custom property on the layer and lets the stylesheet consume it inside calc().
- Geometry attributes such as r and width were never affected, having no CSS property to shadow them.
Why it matters
- constraint Any value you set from script through an attribute is one stylesheet rule away from being ignored, which rules the attribute channel out for anything that changes at runtime.
- decision Choosing the custom-property route moves the arithmetic into the stylesheet and leaves the script publishing a single number, so there is one place to read when geometry looks wrong.
- cost This class of fault bills in wrong fixes rather than broken builds: four rounds of thinning the wrong element before a computed-style read settled it.
- contradiction Treating attributes as the dumb channel predicts the wrong failure; they resolve var() perfectly and lose only on priority, so the risk is override, not evaluation.
The reason this one survives months of use is that nothing in it is invalid. `setAttribute('stroke-width', '0.05')` writes a legal presentation attribute, the browser resolves it against `.handle { stroke-width: 0.35 }` from the stylesheet, and the attribute loses because a presentation attribute ranks below every ordinary declaration [1][2]. No parse error, no console warning, and any test that inspects the DOM reads back exactly the number the code wrote [4].
The arithmetic is what carries it past review. The compensation is `0.35 / zoom`, so at zoom 1 it returns 0.35, which is the number the stylesheet already holds, and the default view is correct [17]. The measurement the author reports, an attribute of 0.05 computing to `0.35px` in Chromium, is the state at zoom 7, where the handles were drawn seven times their intended width [3][16]. An error that is zero at rest and grows with interaction reads as a quirk in the zoom code, not as a dead line in the styling code.
Custom properties fix it for a structural reason rather than a stylistic one. `--px` set on the layer is an ordinary declaration, so it enters the cascade at a level the attribute cannot reach, and the stylesheet does the multiplication inside `calc()` [5]. The division of labour is the real gain: the script publishes one scalar per layer and owns no geometry at all.
The part that should correct the mental model is the finding that turned out to be a non-bug. The author had left a comment claiming `var()` is not substituted in presentation attributes; in fact an attribute and an inline style resolve `oklch(var(--band-l) var(--band-c) 47)` to the same computed colour, including on a node built detached and appended afterwards [7]. So attributes are not an inert side channel. They evaluate custom properties normally and then lose on priority, which means the failure to design against is a silent override, not a failure to compute. That comment cleared a full test suite and a deploy [8], which is roughly what a claim about a browser is worth when nobody pointed a browser at it.
The rest of the writeup has the same shape: authored values agreeing with themselves while computed values say otherwise. Chrome's own focus ring, measured at `outline-width: 2.72727px` in user units, put a blue disc around every clicked point [10], and it took four reports and four wrong fixes before a single `getComputedStyle` call named it [11]; the author's rule covered only `:focus-visible`, the keyboard path where he already draws a ring of his own [12]. The contrast test stayed green because it read the band's raw `stroke`, and a raw colour says nothing about opacity [15], while a 74% opaque band over white sat at 2.29:1, under the 3:1 that WCAG 1.4.11 asks of a graphic [13]. No single lightness cleared both themes at that opacity, so lightness became a theme token, 0.52 on light and 0.74 on dark [14]. The same measuring habit applied to twelve palettes labelled "soft" put them at 5.6:1 to 6.1:1 on a dark ground and 1.03:1 to 1.08:1 on white [18]; on white, the best of them reaches about a third of that 3:1 [19].
What to watch
- Whether other engines compute the same 0.35px from a 0.05 stroke-width attribute; the measurement reported here is Chromium only.
- Any future CSS property that names an SVG geometry attribute would push r and width into the same losing position as stroke-width.
- Whether the compositing contrast check holds up when a third theme or a different band opacity is added, given no single lightness cleared two themes at 74%.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence52
- Adoption12
- Hype gap+12
- Incentives58
- Confidence46
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
The author scaled handle stroke widths by 1 / zoom and wrote the result as a presentation attribute, handle.setAttribute('stroke-width', String(0.35 / zoom)); the value was never used.
- [2]
A stylesheet rule of .handle { stroke-width: 0.35 } outranks the presentation attribute, because a presentation attribute sits at the very bottom of the CSS cascade.
- [3]
Measured in Chromium, a stroke-width attribute of 0.05 computed as 0.35px.
- [4]
Every handle thickened on screen as the user zoomed in, for months, with no error raised anywhere.
- [5]
The fix was a custom property, which is an ordinary declaration and wins where an attribute cannot: layer.style.setProperty('--px', String(1 / zoom)) with .handle { stroke-width: calc(0.35 * var(--px)) }.
- [6]
Geometry attributes such as r and width were unaffected, because they have no CSS counterpart here, so nothing was ever overriding them.
- [7]
var() is substituted in presentation attributes: setAttribute('stroke', 'oklch(var(--band-l) var(--band-c) 47)') and style.stroke both compute to "oklch(0.52 0.09 47)", including on an element built detached and appended afterwards.
- [8]
The author's code comment asserting that var() is not substituted in presentation attributes was wrong, and it passed a full test suite and a deploy.
- [10]
Chrome drew its default focus ring at outline-width: 2.72727px in user units, about 24 screen pixels, producing a fat blue disc around every point the user clicked.
- [11]
The focus ring was reported to the author four times, and four times he thinned something of his own that was not the cause; one line, getComputedStyle(document.activeElement).outline, found it.
- [12]
The author's own focus rule covered only :focus-visible, the keyboard case, and the keyboard case is where he draws a ring of his own.
- [13]
After a request to make colour bands 74% opaque instead of solid, the palette over white landed at 2.29:1 against that white, under the 3:1 that WCAG 1.4.11 asks of a graphic.
- [14]
A sweep of the lightness range found no single value that clears the bar in both themes at that opacity, so lightness became a theme token: 0.52 on light and 0.74 on dark.
- [15]
The author's contrast test stayed green throughout because it read the band's raw stroke, and a raw colour says nothing about opacity; it now composites the colour over the backdrop.
- [16]
At the moment the attribute asked for 0.05, the stylesheet's 0.35 was seven times the intended stroke width.
- [17]
The reported 0.05 attribute corresponds to zoom 7, and at zoom 1 the compensation returns exactly 0.35, the same value the stylesheet holds, so the default view rendered as intended and the divergence scaled with zoom.
- [18]
Hex values pulled from the top twelve coolors.co palettes labelled "soft" each measured between 5.6:1 and 6.1:1 on a dark ground and between 1.03:1 and 1.08:1 on a white one.
- [19]
None of the twelve sampled palettes reaches the 3:1 graphics threshold on a white ground; the highest, 1.08:1, is about 36% of it.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toFour things SVG and CSS did that I did not expect
1 article · August 22, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.