Module 08: Advanced Chart.js — Mixed Charts, Annotations, Export

Modules 05–07 covered the fundamentals of Chart.js: configuration, styling, live data. This module pushes further into the features you will need for a real analytics dashboard — mixed-type charts that combine bars and lines on the same canvas, stacked bars for composition, annotation plugins for threshold bands, data labels, and PNG export so stakeholders can paste charts into slide decks and emails.

Connection to the Overview: Mixed charts and dual axes address the multivariate encoding challenge described in the Data Visualization Overview. Annotations implement the reference lines pattern used in performance dashboards (Core Web Vitals thresholds). Export solves the distribution problem — getting visualizations out of the browser and into reports.

Module Files

Run: Open the HTML files directly in a browser. All use CDN-hosted Chart.js — no npm install needed.

1. Mixed-Type Charts: Bar + Line on One Canvas

A dashboard often needs to show two related but differently-scaled metrics together. The classic example: pageviews (a count, shown as bars) and bounce rate (a percentage, shown as a line). Putting them on the same chart with dual y-axes lets the viewer correlate the two series without flipping between charts.

Chart.js supports this natively. Each dataset can declare its own type, and you assign datasets to different y-axes by referencing yAxisID:

const chart = new Chart(ctx, {
    type: 'bar',  // default type for datasets that don't specify one
    data: {
        labels: ['Jan 1', 'Jan 2', 'Jan 3', /* ... */],
        datasets: [
            {
                label: 'Pageviews',
                type: 'bar',
                data: [1250, 1340, 1180, /* ... */],
                backgroundColor: 'rgba(52, 152, 219, 0.7)',
                yAxisID: 'y'        // left axis
            },
            {
                label: 'Bounce Rate',
                type: 'line',
                data: [42.3, 38.7, 45.1, /* ... */],
                borderColor: '#e74c3c',
                yAxisID: 'y1'       // right axis
            }
        ]
    },
    options: {
        scales: {
            y: {
                position: 'left',
                title: { display: true, text: 'Pageviews' },
                beginAtZero: true
            },
            y1: {
                position: 'right',
                title: { display: true, text: 'Bounce Rate (%)' },
                min: 0,
                max: 100,
                grid: { drawOnChartArea: false }  // don't overlap grids
            }
        }
    }
});
Dual axes can mislead. When two y-axes have independent scales, the viewer may perceive a false correlation between the series. A rising line crossing a falling bar looks dramatic even if the relationship is coincidental. Always label both axes clearly and consider whether two separate charts would be more honest. Use dual axes when the series are genuinely related (e.g., traffic volume and its bounce rate).

Key configuration details:

See mixed-chart.html for a complete working example with 14 days of data.

2. Stacked Bar Charts for Composition

A stacked bar chart shows how a total breaks down into categories. For analytics, the common use case is traffic by browser, traffic by device type, or traffic by referral source over time. Each bar represents a day, and the segments show how much each browser contributed.

const chart = new Chart(ctx, {
    type: 'bar',
    data: {
        labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
        datasets: [
            {
                label: 'Chrome',
                data: [420, 435, 410, 450, 440, 320, 280],
                backgroundColor: '#4285F4'
            },
            {
                label: 'Safari',
                data: [180, 175, 190, 185, 170, 210, 230],
                backgroundColor: '#34A853'
            },
            {
                label: 'Firefox',
                data: [95, 100, 88, 92, 105, 75, 65],
                backgroundColor: '#FF7139'
            },
            {
                label: 'Edge',
                data: [60, 55, 65, 58, 62, 45, 40],
                backgroundColor: '#0078D4'
            },
            {
                label: 'Other',
                data: [25, 30, 22, 28, 18, 20, 15],
                backgroundColor: '#999'
            }
        ]
    },
    options: {
        scales: {
            x: { stacked: true },
            y: { stacked: true, beginAtZero: true }
        }
    }
});

Setting stacked: true on both the x and y scales tells Chart.js to stack the bars rather than placing them side by side. The segments are drawn from bottom to top in dataset order.

When to stack vs. group: Stacked bars are good for showing how parts compose a whole and for comparing totals across categories. Grouped (side-by-side) bars are better for comparing individual segments across categories. If you need to answer "Was Chrome bigger on Monday or Tuesday?", grouped bars make that comparison easier. If you need to answer "Did total traffic grow this week?", stacked bars show the total height.

See stacked-bar.html for the full demo with custom colors.

3. Annotation Plugin: Reference Lines and Bands

Production dashboards almost always need reference lines — fixed horizontal lines or bands that mark thresholds. The classic example in web performance is Core Web Vitals (CWV): Google defines three zones for metrics like Largest Contentful Paint (LCP):

Zone LCP Range Color
Good 0–2500ms Green
Needs Improvement 2500–4000ms Yellow
Poor 4000ms+ Red

Chart.js does not include annotation support in its core. You need the chartjs-plugin-annotation plugin:

<!-- Load Chart.js first, then the annotation plugin -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation"></script>

Once loaded, annotations are configured in options.plugins.annotation:

options: {
    plugins: {
        annotation: {
            annotations: {
                goodZone: {
                    type: 'box',
                    yMin: 0,
                    yMax: 2500,
                    backgroundColor: 'rgba(76, 175, 80, 0.1)',
                    borderWidth: 0,
                    label: {
                        display: true,
                        content: 'Good',
                        position: 'start',
                        color: '#4CAF50',
                        font: { weight: 'bold' }
                    }
                },
                needsImprovementZone: {
                    type: 'box',
                    yMin: 2500,
                    yMax: 4000,
                    backgroundColor: 'rgba(255, 193, 7, 0.1)',
                    borderWidth: 0,
                    label: {
                        display: true,
                        content: 'Needs Improvement',
                        position: 'start',
                        color: '#FF9800',
                        font: { weight: 'bold' }
                    }
                },
                poorZone: {
                    type: 'box',
                    yMin: 4000,
                    yMax: 6000,
                    backgroundColor: 'rgba(244, 67, 54, 0.1)',
                    borderWidth: 0,
                    label: {
                        display: true,
                        content: 'Poor',
                        position: 'start',
                        color: '#F44336',
                        font: { weight: 'bold' }
                    }
                }
            }
        }
    }
}

The annotation plugin supports several annotation types:

Type Use Case
line Horizontal or vertical reference line (e.g., "target: 2.5s")
box Shaded rectangular region (e.g., CWV threshold bands)
point Annotated data point with label
label Free-floating text label at arbitrary coordinates
ellipse Elliptical annotation region

See annotations.html for a complete demo showing LCP values with color-coded threshold bands.

4. Data Labels Plugin

By default, Chart.js shows values only in tooltips (on hover). For printed reports or static screenshots, you often want the values displayed directly on the bars or points. The chartjs-plugin-datalabels plugin handles this:

<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels"></script>
// Register the plugin globally
Chart.register(ChartDataLabels);

const chart = new Chart(ctx, {
    type: 'bar',
    data: { /* ... */ },
    options: {
        plugins: {
            datalabels: {
                anchor: 'end',         // position at the end of the bar
                align: 'top',          // place label above the anchor
                color: '#333',
                font: { weight: 'bold', size: 12 },
                formatter: (value) => value.toLocaleString()
            }
        }
    }
});

Key options for datalabels:

Data labels add clutter. Only enable them when the chart has few enough bars or points that labels do not overlap. For dense time-series data, tooltips are the better choice. Data labels shine in bar charts with 5–15 bars, or in pie/doughnut charts where you want to show percentages.

5. Custom Animations

Chart.js animates charts by default — bars grow upward, lines draw from left to right. You can customize the animation timing and behavior:

options: {
    animation: {
        duration: 1500,              // ms for the initial animation
        easing: 'easeInOutQuart',    // easing function
        delay: (context) => {
            // Stagger bars: each bar starts 100ms after the previous
            return context.dataIndex * 100;
        }
    },
    transitions: {
        active: {
            animation: { duration: 200 }  // faster animation on hover
        }
    }
}

Available easing functions include 'linear', 'easeInQuad', 'easeOutQuad', 'easeInOutQuart', 'easeOutBounce', and many more. The delay callback receives a context object with dataIndex and datasetIndex, allowing staggered animations where each bar or point animates in sequence.

Animation and performance: Animations run on the main thread. For dashboards with many charts or auto-refreshing data, consider disabling animation (animation: false) or keeping durations short. The initial load animation is a nice touch; continuous re-render animations can be distracting and wasteful.

6. PNG Export via toDataURL()

Chart.js renders to a <canvas> element, which means you can export the chart as a PNG image using the native Canvas API. This is one of the most-requested features for analytics dashboards — stakeholders want to paste charts into emails, slide decks, and Confluence pages.

function downloadChart(canvas, filename) {
    // Get the image data as a base64-encoded PNG
    const imageURL = canvas.toDataURL('image/png');

    // Create a temporary download link
    const link = document.createElement('a');
    link.download = filename || 'chart.png';
    link.href = imageURL;

    // Trigger the download
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
}

// Wire it up to a button
document.getElementById('downloadBtn').addEventListener('click', () => {
    const canvas = document.getElementById('myChart');
    downloadChart(canvas, 'analytics-chart.png');
});

The process is simple:

  1. canvas.toDataURL('image/png') serializes the canvas pixels to a base64 PNG string
  2. Create an <a> element with a download attribute
  3. Set its href to the data URL
  4. Programmatically click it to trigger the browser's download dialog

Copy to Clipboard

Modern browsers also support copying canvas content directly to the clipboard, which is even more convenient for pasting into documents:

async function copyChartToClipboard(canvas) {
    try {
        // Convert canvas to a Blob
        const blob = await new Promise(resolve => {
            canvas.toBlob(resolve, 'image/png');
        });

        // Write to clipboard
        await navigator.clipboard.write([
            new ClipboardItem({ 'image/png': blob })
        ]);

        alert('Chart copied to clipboard!');
    } catch (err) {
        console.error('Clipboard write failed:', err);
        alert('Copy failed. Try the download button instead.');
    }
}
Clipboard API requirements: The navigator.clipboard.write() method requires a secure context (HTTPS) and user activation (must be triggered by a click handler). It also requires clipboard-write permission, which most browsers grant automatically for user-initiated actions. Safari has stricter requirements and may not support ClipboardItem with image blobs in all versions.

Background Color on Export

By default, toDataURL() captures the canvas as-is. If your chart has a transparent background (the default for Chart.js), the exported PNG will have a transparent background, which looks odd when pasted on a white slide. You can use the Chart.js beforeDraw plugin to fill the background:

const bgPlugin = {
    id: 'customCanvasBackgroundColor',
    beforeDraw: (chart) => {
        const ctx = chart.canvas.getContext('2d');
        ctx.save();
        ctx.globalCompositeOperation = 'destination-over';
        ctx.fillStyle = 'white';
        ctx.fillRect(0, 0, chart.canvas.width, chart.canvas.height);
        ctx.restore();
    }
};

// Register when creating the chart
new Chart(ctx, {
    type: 'bar',
    data: { /* ... */ },
    options: { /* ... */ },
    plugins: [bgPlugin]
});

See export-png.html for a complete working example with both download and clipboard copy buttons.

7. Putting It Together: A Dashboard Panel

A typical analytics dashboard panel might combine several of these features:

┌─────────────────────────────────────────────────┐ │ Daily Traffic & Bounce Rate [Export] │ │ │ │ Pageviews (bars) Bounce Rate (line) │ │ ┌───────────────────────────────────────────┐ │ │ │ ██ ── 45% │ │ │ │ ██ ██ ── │ │ │ │ ██ ██ ██ ── ── 40% │ │ │ │ ██ ██ ██ ██ ── │ │ │ │ ██ ██ ██ ██ ██ ██ ██ ── 35% │ │ │ └───────────────────────────────────────────┘ │ │ Mon Tue Wed Thu Fri Sat Sun │ │ │ │ ┌─ Chrome ─┐ ┌─ Safari ─┐ ┌─ Firefox ─┐ │ │ │ stacked │ │ stacked │ │ stacked │ │ │ └──────────┘ └──────────┘ └──────────┘ │ └─────────────────────────────────────────────────┘

The pattern is: a mixed chart (bar + line) on top for the headline metrics, stacked bars below for composition breakdown, annotation bands if you have thresholds (like CWV), and an export button in the corner. This is the standard layout for analytics tools like Google Analytics, Mixpanel, and Plausible.

8. When Chart.js Reaches Its Limits

Chart.js is excellent for standard chart types with standard interactions. It covers bar, line, area, pie, doughnut, radar, scatter, bubble, and polar area charts. With plugins, it handles annotations, data labels, zoom, and streaming data.

But sometimes you need a visualization that does not fit any standard chart type:

These visualizations require imperative control over every visual element — you need to decide exactly what SVG elements to create, where to position them, and how they respond to interaction. That is what D3.js provides.

The transition to D3: This is where Chart.js reaches its limits. When the question requires a visual form that doesn't exist as a chart type, D3 is the tool. Chart.js is declarative — you describe what you want and the library renders it. D3 is imperative — you control every element. But first, Module 09 explores the rendering landscape — why Canvas, SVG, CSS, and WebGL exist and when to choose each. Then Modules 10–12 teach you D3, starting from scratch and building up to a force-directed network visualization.

9. Summary