Module 11: D3 Transitions & Interactivity

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.

Connection to the Overview: Transitions and interactivity implement two principles from the Data Visualization Overview: animation as a preattentive cue (drawing the eye to what changed) and details on demand (Shneiderman's mantra: overview first, zoom and filter, then details on demand).

Demo Files

Run: Open the HTML files directly in a browser. Each uses D3 v7 from CDN — no build step required.

Transitions: Animating Property Changes

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.

Easing Functions

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

Transition Timing

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));
Chaining transitions: You can chain multiple transitions sequentially. The second .transition() starts after the first completes:
selection
    .transition().duration(500)
    .attr('fill', '#e74c3c')
    .transition().duration(500)
    .attr('fill', '#16a085');

The Enter/Update/Exit Pattern with Transitions

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:

Old Data: [A=10, B=20, C=30] New Data: [A=25, C=15, D=40] Enter (D): Fade in from opacity 0, grow from height 0 Update (A,C): Smoothly transition to new height Exit (B): Shrink to height 0, fade out, then remove
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);
}
Always use a key function when data changes: Without a key function, D3 matches data to elements by index. If your data gets reordered or items are added/removed from the middle, index-based binding causes the wrong bars to animate. The key function (d => d.key) tells D3 which element belongs to which datum, enabling correct object constancy.

The Key Function

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.

Index-based (no key function): Position 0: /about (430) --> /home (1250) Bar stays, value jumps Position 1: /blog (675) --> /products (890) Confusing! Position 2: /home (1250) --> /blog (675) Key-based (d => d.page): /about bar: position 0 --> position 3 Bar slides to new position /home bar: position 2 --> position 0 Identity preserved! /blog bar: position 1 --> position 2 Clear and intuitive

The .join() Shorthand

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())
    );

Mouse Events on SVG Elements

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);
    });
Arrow functions vs. regular functions: In D3 event handlers, 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.

Common Mouse Events

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

Getting Mouse Coordinates with d3.pointer()

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.

Building a Tooltip

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.

Step 1: Create the Tooltip Element

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');

Step 2: Show on mouseenter, Follow on mousemove, Hide on mouseleave

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.

Tooltip Positioning Strategies

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.

Animated Sortable Bar Chart

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.

Try it: The sortable-bars.html demo implements this exact pattern. Open it in your browser and click the sort buttons to see bars slide smoothly into place.

Combining Transitions with Interactivity

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 interruption: If a user moves the mouse quickly across several bars, each bar's enter/leave transitions may overlap. D3 handles this gracefully — a new transition on an element interrupts and replaces the previous one. You do not need to cancel transitions manually. However, if you are running a long data-update transition and the user hovers mid-animation, the hover transition will interrupt the data transition on that element. For complex cases, use named transitions (.transition("hover") vs .transition("sort")) to keep them independent.

Named Transitions

// 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');

Practical Tips

Summary