Skip to content

Enter animation suppressed when container's CSS pixel width is sub-integer (fractional getBoundingClientRect width) #12256

@ubarkai

Description

@ubarkai

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:

  1. this.resize() at the top of _initialize()
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions