Expected behavior
Both charts animate identically. The enter animation should play regardless of whether the container's width is an integer number of CSS pixels.
Current behavior
The chart in the fractional-width container is rendered immediately in its final state; the animation never plays.
Reproducible sample
https://codepen.io/Uri-Barkai/pen/azBWaXV
Optional extra steps/info to reproduce
No response
Possible solution
Root cause
Two _resize() calls happen synchronously inside the Chart constructor, in this order:
- this.resize() at the top of _initialize()
- this.resize() inside attached(), called by bindResponsiveEvents() → bindEvents(), still within _initialize()
After both calls, this.update() is called by the constructor to start the enter animation.
The bug is in retinaScale() during call #2. After call #1, canvas.width has been set to the integer truncation of deviceWidth:
chart.width = round1(containerWidth) // e.g. 200.4 → 200.4
deviceWidth = round1(chart.width × dpr) // e.g. 200.4 → 200.4 (DPR=1)
// 400.8 (DPR=2)
canvas.width = deviceWidth // 200.4 → stored as integer 200
// 400.8 → stored as integer 400
On call #2 the same deviceWidth is recomputed (same fractional container width), but canvas.width is already the integer. The comparison inside retinaScale():
canvas.width !== deviceWidth // 200 !== 200.4 → true (fractional container)
// 200 !== 200.0 → false (integer container)
For integer containers the check is false, retinaScale() returns false, _resize() exits early, and _doResize is never called. The constructor's this.update() runs on untouched elements, buildOrUpdateControllers()
finds them as new controllers, reset() is called, and the enter animation plays.
For fractional containers the check is always true. retinaScale() returns true, and _doResize('resize') fires — calling this.update('resize') synchronously, before the constructor reaches this.update(). The resize
transition has duration: 0 by default, so _createAnimations skips Animation objects entirely and writes all element properties directly to their final values (target[prop] = value). When the constructor then calls
this.update(), buildOrUpdateControllers() finds controllers already exist (newControllers = []), reset() is never called, and the elements are already at their target values — nothing to animate.
This affects any container whose width is produced by sub-pixel layout (CSS Grid fr units, percentage widths, calc() expressions), and on any device pixel ratio where round1(fractionalWidth × dpr) is non-integer.
Workaround / proposed fix
Setting the resize transition duration to any value greater than zero prevents the premature finalisation:
Chart.defaults.transitions.resize.animation.duration = 400
With duration > 0, update('resize') creates real Animation objects from reset positions. The constructor's subsequent this.update() fires immediately after (same synchronous task, zero elapsed), finds those animations
active, and calls animation.update(default_cfg, value, date). Because no frames have been rendered yet, _from is reset to the starting position and _duration is extended to Math.max(remainingResizeDuration,
defaultDuration). The full enter animation plays.
Context
No response
chart.js version
v4.5.1
Browser name and version
Firefox 150
Link to your project
No response
Expected behavior
Both charts animate identically. The enter animation should play regardless of whether the container's width is an integer number of CSS pixels.
Current behavior
The chart in the fractional-width container is rendered immediately in its final state; the animation never plays.
Reproducible sample
https://codepen.io/Uri-Barkai/pen/azBWaXV
Optional extra steps/info to reproduce
No response
Possible solution
Root cause
Two _resize() calls happen synchronously inside the Chart constructor, in this order:
After both calls, this.update() is called by the constructor to start the enter animation.
The bug is in retinaScale() during call #2. After call #1, canvas.width has been set to the integer truncation of deviceWidth:
chart.width = round1(containerWidth) // e.g. 200.4 → 200.4
deviceWidth = round1(chart.width × dpr) // e.g. 200.4 → 200.4 (DPR=1)
// 400.8 (DPR=2)
canvas.width = deviceWidth // 200.4 → stored as integer 200
// 400.8 → stored as integer 400
On call #2 the same deviceWidth is recomputed (same fractional container width), but canvas.width is already the integer. The comparison inside retinaScale():
canvas.width !== deviceWidth // 200 !== 200.4 → true (fractional container)
// 200 !== 200.0 → false (integer container)
For integer containers the check is false, retinaScale() returns false, _resize() exits early, and _doResize is never called. The constructor's this.update() runs on untouched elements, buildOrUpdateControllers()
finds them as new controllers, reset() is called, and the enter animation plays.
For fractional containers the check is always true. retinaScale() returns true, and _doResize('resize') fires — calling this.update('resize') synchronously, before the constructor reaches this.update(). The resize
transition has duration: 0 by default, so _createAnimations skips Animation objects entirely and writes all element properties directly to their final values (target[prop] = value). When the constructor then calls
this.update(), buildOrUpdateControllers() finds controllers already exist (newControllers = []), reset() is never called, and the elements are already at their target values — nothing to animate.
This affects any container whose width is produced by sub-pixel layout (CSS Grid fr units, percentage widths, calc() expressions), and on any device pixel ratio where round1(fractionalWidth × dpr) is non-integer.
Workaround / proposed fix
Setting the resize transition duration to any value greater than zero prevents the premature finalisation:
Chart.defaults.transitions.resize.animation.duration = 400
With duration > 0, update('resize') creates real Animation objects from reset positions. The constructor's subsequent this.update() fires immediately after (same synchronous task, zero elapsed), finds those animations
active, and calls animation.update(default_cfg, value, date). Because no frames have been rendered yet, _from is reset to the starting position and _duration is extended to Math.max(remainingResizeDuration,
defaultDuration). The full enter animation plays.
Context
No response
chart.js version
v4.5.1
Browser name and version
Firefox 150
Link to your project
No response