Bringing Charts to Life: Animation System, Event Handling, and Interaction
Prerequisites
- ›Articles 1-5
- ›Understanding of requestAnimationFrame and browser event model
- ›Familiarity with easing functions and interpolation concepts
Bringing Charts to Life: Animation System, Event Handling, and Interaction
A static chart is a picture. An interactive, animated chart is an application. This final article covers the three systems that bring Chart.js charts to life: a three-layer animation architecture that smoothly transitions between states, an event handling pipeline that turns DOM events into hover states, and performance optimizations that keep everything responsive. These systems are interconnected—animation completion triggers hover re-evaluation, and interaction decisions depend on whether elements have reached their final positions.
Three-Layer Animation Architecture
Chart.js separates animation concerns into three distinct classes, each with a clearly defined responsibility:
classDiagram
class Animation {
-_active: boolean
-_fn: InterpolatorFunction
-_easing: EasingFunction
-_start: number
-_duration: number
-_from: any
-_to: any
-_target: object
-_prop: string
+tick(date)
+cancel()
+wait(): Promise
}
class Animations {
-_chart: Chart
-_properties: Map
+configure(config)
+update(target, values): Animation[]
-_createAnimations(target, values)
-_animateOptions(target, values)
}
class Animator {
-_charts: Map~Chart, AnimState~
-_running: boolean
-_request: number
+listen(chart, event, cb)
+add(chart, items)
+start(chart)
+stop(chart)
-_refresh()
-_update(date)
}
Animator "1" -- "*" Animations : coordinates
Animations "1" -- "*" Animation : creates/manages
Animation --> Element : writes to target[prop]
Animation handles a single property transition. Animations manages all pending animations for a chart. Animator is the global singleton that runs the requestAnimationFrame loop.
This separation means that adding a new animatable property requires no changes to the frame loop or orchestration logic—you just configure it in the Animations layer.
Per-Property Animation and Interpolation
The Animation class at its core is a property interpolator with easing:
src/core/core.animation.js#L1-L119
The constructor at line 28 sets up the animation by resolving from and to values, selecting an interpolation function based on the value type, and configuring easing:
flowchart TD
INIT["new Animation(cfg, target, prop, to)"] --> TYPE{"Determine type"}
TYPE -->|"typeof from === 'number'"| NUM["interpolators.number<br/>from + (to - from) * factor"]
TYPE -->|"from is color string"| COLOR["interpolators.color<br/>RGBA space mixing via @kurkle/color"]
TYPE -->|"typeof from === 'boolean'"| BOOL["interpolators.boolean<br/>factor > 0.5 ? to : from"]
TYPE -->|"custom cfg.fn"| CUSTOM["User-provided function"]
NUM --> EASING["Apply easing: effects[cfg.easing]"]
COLOR --> EASING
BOOL --> EASING
CUSTOM --> EASING
The tick() method at line 76 is called once per frame by the Animator. It computes elapsed time, applies easing, and writes the interpolated value directly to the target object: this._target[prop] = this._fn(from, to, factor). The element's properties are mutated in place—there's no virtual DOM or diffing.
Color interpolation uses the @kurkle/color library (Chart.js's only runtime dependency) to mix colors in RGBA space. The interpolator converts both colors to Color objects, calls mix(), and returns a hex string. This produces perceptually smooth color transitions.
The wait() method at line 105 returns a Promise that resolves when the animation completes. This is used by the shared options optimization in Animations._animateOptions()—when transitioning to shared options, the system waits for all option animations to complete before swapping to the shared reference.
The Animator Singleton and Frame Loop
The Animator manages the requestAnimationFrame loop for all charts simultaneously:
src/core/core.animator.js#L38-L107
sequenceDiagram
participant Chart1 as Chart A
participant Chart2 as Chart B
participant Anim as Animator
participant RAF as Browser rAF
Chart1->>Anim: start(chartA)
Anim->>Anim: _refresh()
Anim->>RAF: requestAnimationFrame
Chart2->>Anim: start(chartB)
Note over Anim: No new rAF needed<br/>(already running)
RAF->>Anim: _update(timestamp)
Anim->>Anim: Tick Chart A animations
Anim->>Chart1: chartA.draw()
Anim->>Anim: Tick Chart B animations
Anim->>Chart2: chartB.draw()
alt Animations remaining
Anim->>RAF: _refresh() → next frame
else All complete
Anim->>Anim: _running = false
Anim->>Chart1: notify 'complete'
Anim->>Chart2: notify 'complete'
end
A critical design choice: the _refresh() method at line 38 checks if (this._request) return before scheduling a new frame. This means no matter how many charts call start(), only one requestAnimationFrame is ever pending. All charts are batched into a single frame callback, which is more efficient than separate rAF loops.
The _update() loop at line 57 iterates the _charts Map and ticks each chart's animations. When an animation item's _active flag becomes false, it's removed using the swap-and-pop technique (line 83-84) instead of splice(), maintaining O(1) removal cost.
When a chart's animation items array empties, the complete callback is fired. As we saw in Article 2, this callback is onAnimationsComplete, which calls chart.notifyPlugins('afterRender') and the user's animation.onComplete callback.
Event Handling Pipeline: DOM to Chart
Events flow through a multi-stage pipeline from the browser DOM to Chart.js's interaction resolution:
src/core/core.controller.js#L976-L992
sequenceDiagram
participant DOM as Browser DOM
participant Platform as DomPlatform
participant Chart as Chart
participant Plugins as PluginService
participant Interaction as Interaction Module
participant Hover as Hover Styles
DOM->>Platform: mousemove event
Platform->>Platform: Map touch/pointer → mouse type
Platform->>Chart: listener(e, offsetX, offsetY)
Chart->>Chart: _eventHandler(e)
Chart->>Chart: isPointInArea(e)?
Chart->>Plugins: beforeEvent hook (cancelable)
Chart->>Chart: _handleEvent(e, replay, inChartArea)
Chart->>Interaction: getElementsAtEventForMode()
Interaction-->>Chart: active elements[]
Chart->>Chart: onHover callback
Chart->>Chart: onClick callback (if click)
Chart->>Hover: _updateHoverStyles(active, lastActive)
Chart->>Plugins: afterEvent hook
Chart->>Chart: render() if changed
The bindUserEvents() method at line 976 iterates this.options.events (by default: mousemove, mouseout, click, touchstart, touchmove) and registers a listener for each via the platform. The platform normalizes touch and pointer events to mouse equivalents using the EVENT_TYPES mapping:
src/platform/platform.dom.js#L21-L31
Tip: You can customize which events Chart.js listens for by setting
options.events. If you don't need touch support, removingtouchstartandtouchmovereduces event handler overhead. If you only need click interaction, setevents: ['click'].
Interaction Modes and Hit Testing
The Interaction module provides six built-in modes for determining which elements are "active" given a mouse position:
src/core/core.interaction.js#L22-L64
| Mode | Behavior |
|---|---|
point |
Elements that contain the mouse point |
nearest |
Single nearest element to mouse position |
index |
All elements at the same index (vertical slice) |
dataset |
All elements in the nearest dataset |
x |
All elements at the same x position |
y |
All elements at the same y position |
flowchart TD
EVENT["Mouse position (x, y)"] --> MODE{"Interaction mode?"}
MODE -->|nearest| BINARY["binarySearch()<br/>Narrow candidate range"]
MODE -->|index| INDEX["Find closest index<br/>then all elements at that index"]
MODE -->|point| POINT["Test inRange() on<br/>all elements"]
BINARY --> EVAL["evaluateInteractionItems()"]
EVAL --> DIST["Calculate distances"]
DIST --> ACTIVE["Return active elements"]
The binarySearch() function at line 22 is the key performance optimization. For datasets where data is sorted along the index axis (which is the common case), it uses _lookupByKey to perform a binary search, reducing hit testing from O(n) to O(log n). For a chart with 10,000 points, this means ~14 comparisons instead of 10,000.
The binary search has an additional optimization for shared options: when controller._sharedOptions is set, it knows all elements have equal proportions. It can use the first element's getRange() to determine the hit-test boundary and perform a range-based binary search, finding all potentially intersecting elements in a single pass.
The Replay Mechanism and Animation-Aware Hover
The _handleEvent() method contains one of Chart.js's subtlest but most important UX details:
src/core/core.controller.js#L1196-L1238
sequenceDiagram
participant Update as chart.update()
participant Handler as _eventHandler
participant Elements as Elements
participant Hover as Hover Styles
Note over Update: Data changes, elements will animate
Update->>Update: _lastEvent exists?
Update->>Handler: _eventHandler(lastEvent, replay=true)
Handler->>Elements: getActiveElements(useFinalPosition=true)
Note over Elements: getProps() returns animation<br/>target values, not current
Elements-->>Handler: Active elements at final positions
Handler->>Hover: Apply hover styles to final-position elements
Note over Handler: User sees hover on correct<br/>elements even during animation
When update() completes its pipeline, it replays the last mouse event with replay = true at line 531. This replay calls _handleEvent(e, true), which sets useFinalPosition = true when querying active elements. As we explored in Article 5, Element.getProps() with final = true returns animation target values.
The comment block at lines 1199-1211 in the source explains the reasoning: evaluating active elements on every animation frame would be expensive, and during animation, elements haven't reached their final positions yet. By evaluating once with final positions after update(), Chart.js gets the correct hover state without per-frame recalculation.
The determineLastEvent() helper at line 93 handles edge cases: mouseout events clear the last event (no hover when mouse leaves), and click events preserve the previous last event (clicks shouldn't change the stored hover position).
Performance Patterns: Shared Options and Decimation
Two performance patterns deserve special attention because they span multiple systems:
Shared Options
When DatasetController resolves element options and finds that $shared: true (from the needContext() optimization in the config engine—see Article 3), it stores the options object as this._sharedOptions. All elements in the dataset reference this single object.
This has cascading benefits:
- Memory: One object instead of N objects (significant for large datasets)
- Animation:
Animations._animateOptions()detects shared options and defers the swap until animations complete, usingPromise.allon pending animations - Interaction: The binary search optimization in
core.interaction.jsrelies on_sharedOptionsto know that all elements have equal proportions
LTTB Decimation
The Decimation plugin's LTTB algorithm at src/plugins/plugin.decimation.js#L3-L79 reduces data points while preserving visual shape:
flowchart TD
DATA["10,000 data points"] --> CHECK{"points > available pixels?"}
CHECK -->|No| PASS["Use all points"]
CHECK -->|Yes| BUCKET["Divide into N buckets<br/>(N ≈ canvas width in pixels)"]
BUCKET --> TRIANGLE["For each bucket:<br/>Find point forming largest<br/>triangle with neighbors"]
TRIANGLE --> RESULT["~800 representative points"]
RESULT --> RENDER["Render reduced dataset"]
The algorithm works by dividing data into buckets, computing the average point in the next bucket, and selecting the point from the current bucket that forms the largest triangle with the previous selected point and the next bucket's average. This preserves peaks, valleys, and trends while dramatically reducing point count.
The initialization of maxArea = area = -1 at line 57 (instead of the original algorithm's 1) is a bug fix specific to Chart.js: for flat traces where all triangles have zero area, the original code would never set nextA, causing a crash on the next iteration.
Series Conclusion
Over these six articles, we've traced Chart.js from its module organization through the complete data-to-pixel pipeline. We've seen how three carefully designed entry points serve different consumption patterns (Article 1), how the Chart constructor orchestrates a multi-stage update pipeline (Article 2), how a Proxy-based option resolver supports cascading defaults, scriptable options, and live fallback routes (Article 3), how the Registry and Plugin system enable extensibility without special-casing built-in components (Article 4), how Scales, Elements, and Controllers collaborate to transform data into Canvas geometry (Article 5), and how the animation and interaction systems bring it all to life (Article 6).
The overarching design philosophy is uniformity and composition: built-in chart types are registered components, not privileged code; options resolution is a generic scope-walking algorithm, not chart-type-specific logic; and the animation system is property-type-agnostic, working with any value that can be interpolated. This uniformity is what makes Chart.js genuinely extensible—not through escape hatches and overrides, but through the same mechanisms the library uses internally.