Chart

Revenue

$128,400 Increased by 8.2%

  • 2026
  • 2025
Data table
Monthly revenue, in US dollars
Month20262025
Jan82007400
Feb91007900
Mar104008800
Apr98009300
May1190010100
Jun1260010900
Jul1210011400
Aug1380011800
Sep1520012500
Oct1630013900

Orders by channel

1,284 Decreased by 2.1%

  • Online store
  • Marketplace
Data table
Weekly orders by channel
WeekOnline storeMarketplace
W3518296
W36201104
W37176121
W3821498
W3919887
W4018792
@use 'sprucecss/scss/spruce' as *;

.chart-cards {
  @include layout-grid('m', 22rem);
}

.chart-card {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  min-inline-size: 0;
  padding: spacer('m');
  @include layout-stack('s');

  &__header {
    align-items: flex-start;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs') spacer('m');
    justify-content: space-between;
  }

  &__heading {
    @include layout-stack('xxs');
  }

  &__title {
    color: color('muted');
    font-family: inherit;
    font-size: config('font-size-sm', $typography);
    font-weight: 600;
    margin: 0;
  }

  &__value {
    align-items: baseline;
    color: color('heading');
    display: flex;
    flex-wrap: wrap;
    font-family: config('font-family-heading', $typography);
    font-size: font-size('h3');
    font-weight: 700;
    gap: spacer('xs');
    line-height: 1.2;
  }

  &__change {
    font-family: config('font-family-base', $typography);
    font-size: config('font-size-sm', $typography);

    &--up {
      color: color('success', 'alert');

      &::before {
        content: '↑ ';
      }
    }

    &--down {
      color: color('danger', 'alert');

      &::before {
        content: '↓ ';
      }
    }
  }

  &__legend {
    @include clear-list;
    @include layout-cluster('s');
    color: color('muted');
    font-size: config('font-size-sm', $typography);

    li {
      align-items: center;
      display: inline-flex;
      gap: spacer('xxs');
      margin: 0;
    }

    // The items sit side by side: clear the stack margins of the base list style.
    > li {
      margin-block: 0;
      padding-block: 0;
    }
  }

  // The chart script reads the colors of the swatches, so the legend and the chart always match.
  &__swatch {
    background-color: color('primary');
    block-size: 0.75rem;
    border-radius: radius('sm');
    display: inline-block;
    inline-size: 0.75rem;

    &--muted {
      background-color: color-alpha('muted', 0.5);
    }
  }

  &__canvas {
    block-size: 16rem;
    position: relative;

    // Without JavaScript (or if Chart.js fails to load), the canvas stays empty: hide it, the open data table stays visible.
    &:not(:has(canvas[data-chart-ready])) {
      display: none;
    }
  }

  &__data {
    font-size: config('font-size-sm', $typography);

    summary {
      color: color('muted');
      cursor: pointer;
    }

    .table {
      margin-block-start: spacer('xs');
    }
  }
}
<div class="chart-cards">
    <article class="chart-card" aria-labelledby="chart-revenue-title">
        <header class="chart-card__header">
            <div class="chart-card__heading">
                <h3 class="chart-card__title" id="chart-revenue-title">Revenue</h3>
                <p class="chart-card__value">$128,400 <span class="chart-card__change chart-card__change--up"><span class="sr-only">Increased by</span> 8.2%</span></p>
            </div>
            <ul class="chart-card__legend" role="list">
                <li><span class="chart-card__swatch" aria-hidden="true"></span>2026</li>
                <li><span class="chart-card__swatch chart-card__swatch--muted" aria-hidden="true"></span>2025</li>
            </ul>
        </header>
        <div class="chart-card__canvas">
            <canvas data-chart="line" data-chart-table="chart-revenue-data" role="img" aria-label="Line chart of the monthly revenue in 2026 and 2025. The data is in the table below."></canvas>
        </div>
        <details class="chart-card__data" open>
            <summary>Data table</summary>
            <div class="table-responsive">
                <table class="table table--sm" id="chart-revenue-data" data-chart-prefix="$">
                    <caption class="sr-only">Monthly revenue, in US dollars</caption>
                    <thead>
                        <tr><th scope="col">Month</th><th scope="col">2026</th><th scope="col">2025</th></tr>
                    </thead>
                    <tbody>
                        <tr><th scope="row">Jan</th><td>8200</td><td>7400</td></tr>
                        <tr><th scope="row">Feb</th><td>9100</td><td>7900</td></tr>
                        <tr><th scope="row">Mar</th><td>10400</td><td>8800</td></tr>
                        <tr><th scope="row">Apr</th><td>9800</td><td>9300</td></tr>
                        <tr><th scope="row">May</th><td>11900</td><td>10100</td></tr>
                        <tr><th scope="row">Jun</th><td>12600</td><td>10900</td></tr>
                        <tr><th scope="row">Jul</th><td>12100</td><td>11400</td></tr>
                        <tr><th scope="row">Aug</th><td>13800</td><td>11800</td></tr>
                        <tr><th scope="row">Sep</th><td>15200</td><td>12500</td></tr>
                        <tr><th scope="row">Oct</th><td>16300</td><td>13900</td></tr>
                    </tbody>
                </table>
            </div>
        </details>
    </article>
    <article class="chart-card" aria-labelledby="chart-orders-title">
        <header class="chart-card__header">
            <div class="chart-card__heading">
                <h3 class="chart-card__title" id="chart-orders-title">Orders by channel</h3>
                <p class="chart-card__value">1,284 <span class="chart-card__change chart-card__change--down"><span class="sr-only">Decreased by</span> 2.1%</span></p>
            </div>
            <ul class="chart-card__legend" role="list">
                <li><span class="chart-card__swatch" aria-hidden="true"></span>Online store</li>
                <li><span class="chart-card__swatch chart-card__swatch--muted" aria-hidden="true"></span>Marketplace</li>
            </ul>
        </header>
        <div class="chart-card__canvas">
            <canvas data-chart="bar" data-chart-table="chart-orders-data" role="img" aria-label="Bar chart of the weekly orders by channel. The data is in the table below."></canvas>
        </div>
        <details class="chart-card__data" open>
            <summary>Data table</summary>
            <div class="table-responsive">
                <table class="table table--sm" id="chart-orders-data">
                    <caption class="sr-only">Weekly orders by channel</caption>
                    <thead>
                        <tr><th scope="col">Week</th><th scope="col">Online store</th><th scope="col">Marketplace</th></tr>
                    </thead>
                    <tbody>
                        <tr><th scope="row">W35</th><td>182</td><td>96</td></tr>
                        <tr><th scope="row">W36</th><td>201</td><td>104</td></tr>
                        <tr><th scope="row">W37</th><td>176</td><td>121</td></tr>
                        <tr><th scope="row">W38</th><td>214</td><td>98</td></tr>
                        <tr><th scope="row">W39</th><td>198</td><td>87</td></tr>
                        <tr><th scope="row">W40</th><td>187</td><td>92</td></tr>
                    </tbody>
                </table>
            </div>
        </details>
    </article>
</div>
/* global Chart */
(() => {
  // Chart.js must be loaded before this script: https://www.chartjs.org/docs/latest/getting-started/installation.html
  if (typeof Chart === 'undefined') {
    return;
  }

  const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true });

  // Turn any CSS color (custom properties, color-mix(), oklch()) into rgba(), which Chart.js can parse.
  const toRgba = (value, alpha = 1) => {
    probe.clearRect(0, 0, 1, 1);
    probe.fillStyle = 'transparent';
    probe.fillStyle = value;
    probe.fillRect(0, 0, 1, 1);
    const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;

    return `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * alpha * 100) / 100})`;
  };

  // Read the data from the table: the row headers are the labels, the column headers are the series.
  const readTable = (table) => {
    const series = [...table.querySelectorAll('thead th')].slice(1).map((th) => th.textContent.trim());
    const rows = [...table.querySelectorAll('tbody tr')];

    return {
      labels: rows.map((row) => row.querySelector('th').textContent.trim()),
      datasets: series.map((label, index) => ({
        label,
        data: rows.map((row) => Number(row.querySelectorAll('td')[index].textContent.replace(/[^\d.-]/g, '')))
      }))
    };
  };

  // Collect the colors from the card itself, so the chart follows the theme mode and the design style of its context.
  const readTheme = (card) => {
    const style = getComputedStyle(card);
    const swatches = [...card.querySelectorAll('.chart-card__swatch')].map((swatch) => getComputedStyle(swatch).backgroundColor);

    return {
      series: swatches.length ? swatches : [style.getPropertyValue('--spruce-base-color-primary')],
      text: getComputedStyle(card.querySelector('.chart-card__title')).color,
      grid: style.borderBlockStartColor,
      surface: style.backgroundColor,
      heading: getComputedStyle(card.querySelector('.chart-card__value')).color,
      font: style.fontFamily
    };
  };

  const applyTheme = (chart, card) => {
    const theme = readTheme(card);
    const isLine = chart.config.type === 'line';

    chart.data.datasets.forEach((dataset, index) => {
      const color = theme.series[index % theme.series.length];

      dataset.borderColor = toRgba(color);
      dataset.backgroundColor = isLine ? toRgba(color, index === 0 ? 0.12 : 0) : toRgba(color);
      dataset.pointBackgroundColor = toRgba(color);
      dataset.borderDash = isLine && index > 0 ? [6, 4] : [];
    });

    const { scales, plugins } = chart.options;

    [scales.x, scales.y].forEach((scale) => {
      scale.ticks.color = toRgba(theme.text);
      scale.ticks.font = { family: theme.font };
      scale.grid.color = toRgba(theme.grid);
      scale.border.color = toRgba(theme.grid);
    });

    Object.assign(plugins.tooltip, {
      backgroundColor: toRgba(theme.surface),
      borderColor: toRgba(theme.grid),
      titleColor: toRgba(theme.heading),
      bodyColor: toRgba(theme.text),
      titleFont: { family: theme.font },
      bodyFont: { family: theme.font }
    });

    chart.update('none');
  };

  const charts = [];
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  document.querySelectorAll('canvas[data-chart]').forEach((canvas) => {
    const table = document.getElementById(canvas.dataset.chartTable);
    const card = canvas.closest('.chart-card');
    const prefix = table.dataset.chartPrefix || '';
    const type = canvas.dataset.chart;
    const format = new Intl.NumberFormat(document.documentElement.lang || 'en', { notation: 'compact' });

    const chart = new Chart(canvas, {
      type,
      data: readTable(table),
      options: {
        animation: reducedMotion ? false : { duration: 600 },
        maintainAspectRatio: false,
        interaction: { mode: 'index', intersect: false },
        elements: {
          line: { borderWidth: 2, fill: true, tension: 0.35 },
          point: { radius: 0, hoverRadius: 4 },
          bar: { borderRadius: 4, borderSkipped: 'start' }
        },
        datasets: {
          bar: { categoryPercentage: 0.7, barPercentage: 0.9 }
        },
        plugins: {
          legend: { display: false },
          tooltip: {
            borderWidth: 1,
            padding: 10,
            usePointStyle: true,
            callbacks: {
              label: (context) => `${context.dataset.label}: ${prefix}${context.parsed.y.toLocaleString(document.documentElement.lang || 'en')}`
            }
          }
        },
        scales: {
          x: { grid: { display: false }, border: {}, ticks: {} },
          y: {
            beginAtZero: true,
            grid: {},
            border: { display: false },
            ticks: { maxTicksLimit: 5, callback: (value) => `${prefix}${format.format(value)}` }
          }
        }
      }
    });

    applyTheme(chart, card);
    canvas.dataset.chartReady = '';
    // The chart replaces the table visually; keep the table in a closed disclosure.
    table.closest('details')?.removeAttribute('open');
    charts.push({ chart, card });
  });

  // Re-read the colors when the theme mode or the design style changes (after the color transitions end).
  let timer = null;
  const refresh = () => {
    clearTimeout(timer);
    timer = setTimeout(() => charts.forEach(({ chart, card }) => applyTheme(chart, card)), 300);
  };

  new MutationObserver(refresh).observe(document.documentElement, {
    attributes: true,
    subtree: true,
    attributeFilter: ['class', 'data-theme-mode', 'data-spruce-style']
  });
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', refresh);
})();

Chart

Line and bar chart cards powered by Chart.js. The data comes from an HTML table in the card, and the chart takes its colors and font from the Spruce tokens, so it follows the dark mode and every design style.

Dependency: Chart.js

This is the only Spruce UI component with an external dependency. Drawing interactive charts (axes, scaling, tooltips, hover states, resizing) is a lot of code, and Chart.js is the most widely used open-source charting library: MIT licensed, about 70 KB gzipped (version 4.5.1), a single file from the CDN (its only dependency, a small color helper, is bundled), renders to canvas.

If you only need simple, static charts, use the dependency-free Bar Chart, Donut Chart or the sparkline of the Stat card instead.

Install from a CDN

Load Chart.js before the component script. Pin the version, so an update can't break your charts:

<script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
<script defer src="/js/chart.js"></script>

Both scripts are deferred, so they run in this order after the HTML is parsed.

Install with npm

npm install chart.js

Then expose the Chart object before the component script runs (or replace the global in the script with the import):

import Chart from 'chart.js/auto';

window.Chart = Chart;

chart.js/auto registers every chart type and plugin. For a smaller bundle, register only what the component uses, see the Chart.js integration guide:

import { Chart, LineController, BarController, LineElement, BarElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip } from 'chart.js';

Chart.register(LineController, BarController, LineElement, BarElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip);
window.Chart = Chart;

The script is written for Chart.js 4.

Anatomy

  • .chart-card__header: the title, the headline value with the change, and the legend.
  • .chart-card__canvas: the canvas with data-chart="line" or data-chart="bar" and data-chart-table pointing to the id of the data table. Its height is set here (16rem); the chart fills it.
  • .chart-card__data: a details element with the data table. The row headers are the labels (x axis), the column headers are the series, the cells are the values. Add data-chart-prefix="$" to the table to prefix the values in the axis and the tooltip.

Accessibility and Usability

  • A canvas is invisible to screen readers, so it has role="img" and an aria-label that says what the chart shows and where the data is.
  • The data table is always in the document: everyone can read the exact values, and it works as a fallback. Without JavaScript (or if Chart.js doesn't load), the canvas is hidden and the table stays open; the script closes it once the chart is drawn.
  • The legend is HTML (not drawn into the canvas), so it is readable and translatable. The second line series is dashed, so the series are distinguishable without color.
  • The animation is turned off when prefers-reduced-motion: reduce is set.

Technical Details

  • Colors: the script reads the computed colors of the legend swatches (.chart-card__swatch), the title, the border and the surface of the card. Change the series colors in the SCSS, and the chart follows. Values like color-mix() or oklch() are converted to rgba() through a 1×1 canvas, because Chart.js only parses the classic color formats.
  • Themes and styles: a MutationObserver watches the class, data-theme-mode and data-spruce-style attributes, and the charts redraw with the new colors. Adapt the attribute list if your theme switcher uses something else.
  • The card grid (.chart-cards) puts the cards side by side when there is room (layout-grid with a 22rem minimum).

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.