In Module 10 you learned to select elements, bind data, and use the enter/update/exit pattern to render a static bar chart in D3. Static charts are useful, but the real power of D3 becomes visible when your data changes. This module covers the two capabilities that make D3 stand apart from declarative charting libraries: animated transitions and fine-grained mouse interaction.
Run: Open the HTML files directly in a browser. Each uses D3 v7 from CDN — no build step required.
A D3 transition smoothly interpolates an element's attributes or styles from their current values to new target values over a specified duration. The API is a single method chain inserted between your selection and the attribute setter:
// Instantly set the height (no animation)
d3.select('rect')
.attr('height', 200);
// Animate the height change over 750ms
d3.select('rect')
.transition()
.duration(750)
.attr('height', 200);
The .transition() call returns a transition object instead of a selection. Any .attr(), .style(), or .text() calls chained after it will be interpolated over the given duration(). D3 automatically chooses the right interpolator — numeric for numbers, color for colors, string for strings with embedded numbers.
By default, transitions use d3.easeCubic, which starts slow, accelerates, and decelerates — giving a natural feel. You can change it:
selection
.transition()
.duration(750)
.ease(d3.easeLinear) // constant speed
.attr('width', 300);
selection
.transition()
.duration(750)
.ease(d3.easeBounce) // bounces at the end
.attr('cy', 350);
| Easing Function | Behavior | Use Case |
|---|---|---|
d3.easeCubic |
Slow-fast-slow (default) | Most transitions |
d3.easeLinear |
Constant speed | Progress bars, loading |
d3.easeBounce |
Bounces at endpoint | Playful emphasis |
d3.easeElastic |
Springy overshoot | Attention-grabbing |
d3.easeBack |
Slight overshoot then settle | Subtle emphasis |
selection
.transition()
.duration(750) // how long the animation runs (ms)
.delay(200) // wait before starting (ms)
.attr('height', newHeight);
// Staggered delays per element (creates a wave effect)
selection
.transition()
.duration(500)
.delay((d, i) => i * 100) // each bar starts 100ms after the previous
.attr('height', d => yScale(d.value));
.transition() starts after the first completes:
selection
.transition().duration(500)
.attr('fill', '#e74c3c')
.transition().duration(500)
.attr('fill', '#16a085');
In Module 09 you saw enter/update/exit for initial rendering. When data changes, the same pattern handles three cases: new data points that need new elements (enter), existing elements whose values changed (update), and old elements whose data disappeared (exit). Transitions make each case visually distinct:
function update(data) {
const bars = svg.selectAll('rect')
.data(data, d => d.key); // key function for object constancy
// EXIT: remove bars whose data is gone
bars.exit()
.transition().duration(500)
.attr('height', 0)
.attr('y', height)
.style('opacity', 0)
.remove();
// ENTER: create new bars at zero height
const enter = bars.enter()
.append('rect')
.attr('x', d => xScale(d.key))
.attr('y', height)
.attr('width', xScale.bandwidth())
.attr('height', 0)
.attr('fill', '#16a085')
.style('opacity', 0);
// ENTER + UPDATE: merge and transition to final position
enter.merge(bars)
.transition().duration(750)
.attr('x', d => xScale(d.key))
.attr('y', d => yScale(d.value))
.attr('width', xScale.bandwidth())
.attr('height', d => height - yScale(d.value))
.attr('fill', '#16a085')
.style('opacity', 1);
}
d => d.key) tells D3 which element belongs to which datum, enabling correct object constancy.
The second argument to .data() is a key function — a function that returns a unique identifier for each datum. It is the mechanism that gives D3 object constancy: the ability to track which visual element corresponds to which data point across updates.
// Without key function: matches by index
svg.selectAll('rect')
.data(newData); // element[0] gets newData[0], etc.
// With key function: matches by identity
svg.selectAll('rect')
.data(newData, d => d.page); // element for "/home" gets the "/home" datum
This distinction matters most when you sort or filter data. Consider a bar chart of pages sorted alphabetically. If you re-sort by pageview count, index-based binding would make each bar's height change to a different page's value — visually confusing. With a key function, the bar for "/home" keeps its identity and smoothly slides to its new position.
D3 v6+ introduced .join() as a cleaner alternative to the manual enter/update/exit pattern. It takes up to three callback functions:
svg.selectAll('rect')
.data(data, d => d.key)
.join(
enter => enter.append('rect')
.attr('fill', '#16a085')
.attr('y', height)
.attr('height', 0)
.call(enter => enter.transition().duration(750)
.attr('y', d => yScale(d.value))
.attr('height', d => height - yScale(d.value))),
update => update
.call(update => update.transition().duration(750)
.attr('y', d => yScale(d.value))
.attr('height', d => height - yScale(d.value))),
exit => exit
.call(exit => exit.transition().duration(500)
.attr('height', 0)
.attr('y', height)
.remove())
);
Because D3 renders SVG (not Canvas), every visual element is a DOM node that can receive native mouse events. This is one of SVG's major advantages over Canvas for interactive charts.
svg.selectAll('rect')
.on('mouseenter', function(event, d) {
// 'this' is the DOM element (the rect)
// 'd' is the bound datum
d3.select(this)
.attr('fill', '#0e7c6b'); // highlight on hover
})
.on('mouseleave', function(event, d) {
d3.select(this)
.attr('fill', '#16a085'); // restore original color
})
.on('click', function(event, d) {
console.log('Clicked:', d.page, d.views);
});
this refers to the DOM element that received the event. If you use an arrow function ((event, d) => { ... }), this will be the enclosing scope instead. Use a regular function keyword when you need to reference the element via this, or use event.currentTarget with arrow functions.
| Event | Fires When | Typical Use |
|---|---|---|
mouseenter |
Cursor enters the element | Highlight bar, show tooltip |
mouseleave |
Cursor leaves the element | Remove highlight, hide tooltip |
mousemove |
Cursor moves within the element | Reposition tooltip to follow cursor |
click |
Element is clicked | Drill-down, filter, select |
When building tooltips or crosshairs, you need to know where the mouse is relative to a container element. d3.pointer(event) returns an [x, y] array of coordinates relative to the element that received the event:
svg.on('mousemove', function(event) {
const [mx, my] = d3.pointer(event);
console.log(`Mouse at SVG coordinates: (${mx}, ${my})`);
});
// To get coordinates relative to a different container:
svg.on('mousemove', function(event) {
const [mx, my] = d3.pointer(event, document.body);
// mx, my are now relative to the body element
});
This replaces the older D3 v5 methods d3.mouse() and d3.clientPoint(), which were removed in D3 v6.
A tooltip in D3 is just a regular HTML <div> that you show, hide, and reposition in response to mouse events. It is not an SVG element — using an HTML div gives you full CSS styling (shadows, rounded corners, rich text formatting) that SVG text elements cannot easily achieve.
const tooltip = d3.select('body')
.append('div')
.style('position', 'absolute')
.style('background', 'rgba(0, 0, 0, 0.85)')
.style('color', '#fff')
.style('padding', '8px 12px')
.style('border-radius', '4px')
.style('font-size', '13px')
.style('pointer-events', 'none') // don't block mouse events
.style('opacity', 0) // hidden by default
.style('transition', 'opacity 0.15s');
svg.selectAll('rect')
.on('mouseenter', function(event, d) {
// Highlight the bar
d3.select(this).attr('fill', '#0e7c6b');
// Show the tooltip
tooltip
.style('opacity', 1)
.html(`<strong>${d.page}</strong><br>${d.views.toLocaleString()} views`);
})
.on('mousemove', function(event) {
// Reposition near the cursor
tooltip
.style('left', (event.pageX + 15) + 'px')
.style('top', (event.pageY - 10) + 'px');
})
.on('mouseleave', function() {
// Restore bar color and hide tooltip
d3.select(this).attr('fill', '#16a085');
tooltip.style('opacity', 0);
});
pointer-events: none is essential: Without it, the tooltip div can intercept mouse events as the cursor moves. This causes the bar's mouseleave to fire (because the cursor entered the tooltip), which hides the tooltip, which re-triggers mouseenter on the bar — creating an annoying flicker loop.
The approach above uses event.pageX and event.pageY to position relative to the page. Two other common strategies:
| Strategy | How | Pros/Cons |
|---|---|---|
| Page-relative | event.pageX/pageY + offset |
Simple; works everywhere. Can go off-screen at edges. |
| SVG-relative | d3.pointer(event) + SVG container offset |
Stays within chart bounds. More math required. |
| Fixed to bar | Position above the bar using scale values | Doesn't follow cursor. Clean for static comparison. |
Combining transitions, key functions, and event handlers produces one of D3's signature patterns: a bar chart that smoothly re-sorts itself when the user clicks a button. Here is the complete pattern:
const data = [
{ page: '/home', views: 1250 },
{ page: '/about', views: 430 },
{ page: '/products', views: 890 },
{ page: '/contact', views: 320 },
{ page: '/blog', views: 675 },
{ page: '/pricing', views: 510 },
{ page: '/docs', views: 780 },
{ page: '/support', views: 290 }
];
// Original order for reset
const originalOrder = data.map(d => d.page);
// xScale uses .domain() to control bar order
const xScale = d3.scaleBand()
.domain(data.map(d => d.page))
.range([0, width])
.padding(0.2);
function sortBars(comparator) {
// Re-sort the data
const sorted = [...data].sort(comparator);
// Update the xScale domain to reflect new order
xScale.domain(sorted.map(d => d.page));
// Transition bars to new x positions
svg.selectAll('rect')
.transition()
.duration(750)
.attr('x', d => xScale(d.page));
// Transition axis labels too
svg.selectAll('.x-label')
.transition()
.duration(750)
.attr('x', d => xScale(d.page) + xScale.bandwidth() / 2);
}
// Button handlers
d3.select('#sort-asc').on('click', () => {
sortBars((a, b) => a.views - b.views);
});
d3.select('#sort-desc').on('click', () => {
sortBars((a, b) => b.views - a.views);
});
d3.select('#sort-original').on('click', () => {
xScale.domain(originalOrder);
svg.selectAll('rect')
.transition().duration(750)
.attr('x', d => xScale(d.page));
svg.selectAll('.x-label')
.transition().duration(750)
.attr('x', d => xScale(d.page) + xScale.bandwidth() / 2);
});
The key insight is that sorting does not change the data values — it changes the x-scale domain order. By updating xScale.domain() and then transitioning the x attribute, bars slide to their new positions while maintaining their identity and height.
Transitions and mouse events work together naturally. A common pattern is to use short transitions for hover effects instead of instant attribute changes, giving the UI a polished feel:
svg.selectAll('rect')
.on('mouseenter', function(event, d) {
d3.select(this)
.transition().duration(150)
.attr('fill', '#0e7c6b')
.attr('opacity', 0.9);
})
.on('mouseleave', function(event, d) {
d3.select(this)
.transition().duration(300)
.attr('fill', '#16a085')
.attr('opacity', 1);
});
.transition("hover") vs .transition("sort")) to keep them independent.
// These two transitions run independently — they have different names
svg.selectAll('rect')
.transition('sort')
.duration(750)
.attr('x', d => xScale(d.page));
svg.selectAll('rect')
.transition('hover')
.duration(150)
.attr('fill', '#0e7c6b');
.delay((d, i) => i * 50) sparingly. Staggered delays create a wave effect that looks impressive but slows down the user's ability to read the chart. Use them for initial load, not for repeated updates.tabindex and focus/blur handlers alongside mouse events..transition().duration(750) smoothly interpolates attribute and style changes over timed3.easeCubic, d3.easeBounce, etc.) control the acceleration curve.data(newData, d => d.key)) tells D3 which elements map to which data, enabling object constancy across updatesmouseenter, mouseleave, mousemove, click) work natively on SVG elementsd3.pointer(event) returns mouse coordinates relative to a containerevent.pageX/pageY; use pointer-events: none to prevent flickerx attribute.transition("name")) prevent hover effects from interrupting data transitions