A minimal reproduction for a WebKit bug. When a transitioned width or max-width changes to a calc() that mixes a percentage and a NaN length (for example calc(50% + NaN * 1px)), WebKit cancels and restarts the transition on every frame and never finishes it. With ?noguard, the loop runs forever and the page’s memory keeps growing.
minimal.html stops itself after 50 transitionrun events. Add ?noguard to remove that guard; the tab may then run out of memory.
<style>
#container { width: 400px; }
#box { width: 100px; height: 20px; background: teal; transition: all 0.2s; }
#box.nan { width: calc(50% + var(--n) * 1px); }
</style>
<div id="container"><div id="box"></div></div>
<script>
box.style.setProperty("--n", String(Number.NaN));
getComputedStyle(box).width;
requestAnimationFrame(() => box.classList.add("nan"));
</script>
Per CSS Values 4, a top-level calculation that produces NaN is censored to 0. The width transitions once from 100px to 0px, fires one transitionrun / transitionstart / transitionend, and stops.
FAIL: transitionrun fired more than 50 times (transition restart loop)
computed width: 0px
offsetWidth: 0
running animations: 1
events: {"transitionrun":51,"transitionstart":50,"transitioncancel":50,"transitionend":0}
Chromium 147 passes minimal.html and all 60 cases in matrix.html.
matrix.html crosses property (width, max-width), NaN source (NaN keyword, 0px / 0, var()), unit (px, %, mixed), transition (none, all, property-specific) and direction (100px → NaN, NaN → 100px).
Every failure involves the mixed calc(50% + NaN * 1px). All other NaN forms pass, including calc(NaN * 1%) and calc(var(--n) * 1%).
| Case | Result |
|---|---|
Static, width: calc(50% + NaN * 1px) |
Pass. Used width is 0. |
Static, max-width: calc(50% + NaN * 1px) |
Layout is correct (width 0), but the computed value serializes as calc(50% + NaN * 1px). Chromium returns 0%. |
Transition 100px → calc(50% + NaN * 1px) |
About 41 restarts in 700 ms (run/start/cancel/end = 42/42/41/0). Mid-transition computed value: calc(47.834909% + NaN * 1px). |
Transition calc(50% + NaN * 1px) → 100px |
One extra transition (2/1/1/1). |
The behavior is the same with transition: all and with a property-specific transition.
A calc() that mixes percentage and length cannot be resolved until layout, so it stays an expression in the computed style. Layout censors NaN correctly, but the transition logic appears to compare these unresolved expressions directly. Because NaN never equals itself, the after-change style always looks different from the running transition’s end value, so the transition is cancelled and restarted on every style update.