@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;
}
> li {
margin-block: 0;
padding-block: 0;
}
}
&__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;
&: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>
(() => {
if (typeof Chart === 'undefined') {
return;
}
const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
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})`;
};
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, '')))
}))
};
};
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 = '';
table.closest('details')?.removeAttribute('open');
charts.push({ chart, card });
});
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);
})();