Bootstrap 5 Animated Line Chart with Tooltips
An animated line chart of yearly revenue with a soft area fill, gridlines and tooltips that appear on hover or keyboard focus.
<section class="wdw-stage">
<div class="container" style="max-width:860px">
<div class="wdw-card p-4 p-md-5">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<span class="wdw-eyebrow">Annual report</span>
<h2 class="wdw-heading h3 mt-1 mb-1">Yearly revenue</h2>
<p class="wdw-muted small mb-0">In millions, 2008 to 2015. Hover or focus a point for details.</p>
</div>
<div class="text-md-end">
<div class="display-6 fw-bold text-body-emphasis lh-1" id="chartPeak">39.9</div>
<div class="small wdw-muted mt-1">Peak revenue, millions</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-4"><div class="bg-body-tertiary rounded-4 p-3 h-100"><div class="small wdw-muted">Average</div><div class="fw-bold fs-5 text-body-emphasis" id="kpiAvg">-</div></div></div>
<div class="col-4"><div class="bg-body-tertiary rounded-4 p-3 h-100"><div class="small wdw-muted">Best year</div><div class="fw-bold fs-5 text-body-emphasis" id="kpiBest">-</div></div></div>
<div class="col-4"><div class="bg-body-tertiary rounded-4 p-3 h-100"><div class="small wdw-muted">Change</div><div class="fw-bold fs-5 text-body-emphasis" id="kpiChange">-</div></div></div>
</div>
<div class="btn-group mb-3 rounded-pill" role="group" aria-label="Year range" id="chartRange">
<button type="button" class="btn btn-outline-primary rounded-start-pill active" data-range="0,7" aria-pressed="true">2008-2015</button>
<button type="button" class="btn btn-outline-primary" data-range="0,3" aria-pressed="false">2008-2011</button>
<button type="button" class="btn btn-outline-primary rounded-end-pill" data-range="4,7" aria-pressed="false">2012-2015</button>
</div>
<div class="wdw-chart position-relative">
<svg id="lineChart" role="group" aria-label="Line chart of yearly revenue in millions, 2008 to 2015, peaking at 39.9 in 2009"></svg>
<div class="wdw-tip" id="chartTip" aria-hidden="true" hidden></div>
</div>
</div>
</div>
</section>
/* Web Designer Wall library style — shared base for every Bootstrap 5 snippet.
Indigo accent, Plus Jakarta Sans, soft cards, pill buttons. Works in light and dark. */
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap");
:root,
[data-bs-theme="light"] {
--wdw-font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--bs-body-font-family: var(--wdw-font);
--bs-primary: #4f46e5;
--bs-primary-rgb: 79, 70, 229;
--bs-primary-text-emphasis: #3730a3;
--bs-primary-bg-subtle: #eef2ff;
--bs-primary-border-subtle: #c7d2fe;
--bs-link-color: #4f46e5;
--bs-link-color-rgb: 79, 70, 229;
--bs-link-hover-color: #4338ca;
--bs-link-hover-color-rgb: 67, 56, 202;
--bs-focus-ring-color: rgba(79, 70, 229, 0.25);
--bs-border-radius: 0.75rem;
--bs-border-radius-lg: 1rem;
--bs-border-radius-xl: 1.25rem;
--wdw-radius-card: 1.25rem;
--wdw-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 16px 40px -16px rgba(16, 24, 40, 0.22);
--wdw-shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 12px -4px rgba(16, 24, 40, 0.12);
--wdw-stage: #f5f6fb;
}
[data-bs-theme="dark"] {
--bs-primary: #818cf8;
--bs-primary-rgb: 129, 140, 248;
--bs-primary-text-emphasis: #c7d2fe;
--bs-primary-bg-subtle: rgba(129, 140, 248, 0.14);
--bs-primary-border-subtle: rgba(129, 140, 248, 0.35);
--bs-link-color: #a5b4fc;
--bs-link-color-rgb: 165, 180, 252;
--bs-link-hover-color: #c7d2fe;
--bs-link-hover-color-rgb: 199, 210, 254;
--bs-focus-ring-color: rgba(129, 140, 248, 0.35);
--wdw-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 16px 40px -16px rgba(0, 0, 0, 0.7);
--wdw-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px -4px rgba(0, 0, 0, 0.5);
--wdw-stage: #0f1117;
}
body { font-family: var(--wdw-font); -webkit-font-smoothing: antialiased; }
/* Bootstrap compiles its blue into these components; point them at the accent. */
.btn-primary {
--bs-btn-bg: var(--bs-primary); --bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: #4338ca; --bs-btn-hover-border-color: #4338ca;
--bs-btn-active-bg: #3730a3; --bs-btn-active-border-color: #3730a3;
--bs-btn-disabled-bg: var(--bs-primary); --bs-btn-disabled-border-color: var(--bs-primary);
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
[data-bs-theme="dark"] .btn-primary { --bs-btn-color: #0f1117; --bs-btn-hover-bg: #a5b4fc; --bs-btn-hover-border-color: #a5b4fc; --bs-btn-hover-color: #0f1117; --bs-btn-active-color: #0f1117; }
.btn-outline-primary {
--bs-btn-color: var(--bs-primary); --bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary); --bs-btn-hover-border-color: var(--bs-primary);
--bs-btn-active-bg: var(--bs-primary); --bs-btn-active-border-color: var(--bs-primary);
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
.btn { --bs-btn-font-weight: 600; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: .5rem; }
.btn-sm { min-height: 36px; }
.form-control, .form-select { min-height: 44px; border-radius: .75rem; }
.form-control:focus, .form-select:focus, .form-check-input:focus { border-color: rgba(var(--bs-primary-rgb), .6); box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.form-check-input:checked { background-color: var(--bs-primary); border-color: var(--bs-primary); }
.nav-pills { --bs-nav-pills-link-active-bg: var(--bs-primary); }
.nav { --bs-nav-link-color: var(--bs-secondary-color); --bs-nav-link-hover-color: var(--bs-emphasis-color); }
.pagination { --bs-pagination-active-bg: var(--bs-primary); --bs-pagination-active-border-color: var(--bs-primary); --bs-pagination-color: var(--bs-body-color); --bs-pagination-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.progress, .progress-stacked { --bs-progress-bar-bg: var(--bs-primary); }
.list-group { --bs-list-group-active-bg: var(--bs-primary); --bs-list-group-active-border-color: var(--bs-primary); }
.dropdown-menu { --bs-dropdown-link-active-bg: var(--bs-primary); border-radius: .875rem; box-shadow: var(--wdw-shadow-sm); }
.accordion { --bs-accordion-active-bg: var(--bs-primary-bg-subtle); --bs-accordion-active-color: var(--bs-primary-text-emphasis); --bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.badge { font-weight: 600; }
/* Library building blocks — use these instead of re-inventing them per snippet. */
.wdw-stage { min-height: 100vh; display: flex; align-items: center; background: var(--wdw-stage); padding-block: 3rem; }
.wdw-card { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color-translucent); border-radius: var(--wdw-radius-card); box-shadow: var(--wdw-shadow); }
.wdw-card-sm { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color-translucent); border-radius: 1rem; box-shadow: var(--wdw-shadow-sm); }
.wdw-aurora { position: relative; color: #fff; background: radial-gradient(60% 55% at 100% 0, #818cf8 0, transparent 70%), radial-gradient(65% 60% at 0 100%, #ec4899 0, transparent 65%), #1e1b4b; }
.wdw-eyebrow { display: inline-block; font-size: .75rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--bs-primary); }
.wdw-heading { font-weight: 800; letter-spacing: -.02em; color: var(--bs-emphasis-color); }
.wdw-muted { color: var(--bs-secondary-color); }
.wdw-icon { width: 2.75rem; height: 2.75rem; flex: none; display: inline-flex; align-items: center; justify-content: center; border-radius: .875rem; background: var(--bs-primary-bg-subtle); color: var(--bs-primary); }
/* Component */
.wdw-chart svg { width: 100%; display: block; overflow: visible; }
.wdw-chart .grid { stroke: var(--bs-border-color); stroke-dasharray: 3 5; }
.wdw-chart .tick { fill: var(--bs-secondary-color); font-size: 12px; font-family: var(--wdw-font); }
@media (max-width: 575.98px) { .wdw-chart .tick { font-size: 11px; } }
.wdw-chart .line { fill: none; stroke: var(--bs-primary); stroke-width: 3; stroke-linecap: round; }
.wdw-chart .hit { fill: transparent; cursor: pointer; outline: none; }
.wdw-chart .dot { fill: var(--bs-body-bg); stroke: var(--bs-primary); stroke-width: 2.5; pointer-events: none; transition: r .15s; }
.wdw-chart .hit:hover + .dot, .wdw-chart .hit:focus-visible + .dot { r: 8; fill: var(--bs-primary); }
.wdw-chart .hit:focus-visible { stroke: var(--bs-primary); stroke-width: 2; }
.wdw-tip { position: absolute; transform: translate(-50%, -140%); pointer-events: none; background: var(--bs-emphasis-color); color: var(--bs-body-bg); font-size: .8125rem; font-weight: 600; padding: .35rem .7rem; border-radius: .6rem; white-space: nowrap; box-shadow: var(--wdw-shadow-sm); }
.wdw-tip small { font-weight: 500; opacity: .7; margin-right: .4rem; }
#chartRange .btn { min-height: 44px; }
@media (prefers-reduced-motion: reduce) { .wdw-chart .line, .wdw-chart .dot { transition: none; } }
#chartRange .btn { padding-inline: .5rem; white-space: nowrap; font-size: .875rem; }
function initLineChart() {
const svg = document.getElementById('lineChart');
const tip = document.getElementById('chartTip');
const rangeBox = document.getElementById('chartRange');
if (!svg || !tip) return;
const NS = 'http://www.w3.org/2000/svg';
const all = [['2008', 10], ['2009', 39.9], ['2010', 17], ['2011', 30], ['2012', 5.3], ['2013', 38.4], ['2014', 15.7], ['2015', 9]];
let range = [0, 7];
const el = (n, a, p) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); (p || svg).appendChild(e); return e; };
function kpis(data) {
const avg = data.reduce((s, d) => s + d[1], 0) / data.length;
const best = data.reduce((m, d) => (d[1] > m[1] ? d : m));
const ch = ((data[data.length - 1][1] - data[0][1]) / data[0][1]) * 100;
document.getElementById('kpiAvg').textContent = avg.toFixed(1);
document.getElementById('kpiBest').textContent = best[0];
document.getElementById('kpiChange').textContent = (ch >= 0 ? '+' : '') + Math.round(ch) + '%';
}
function draw(animate) {
const data = all.slice(range[0], range[1] + 1);
kpis(data);
const W = Math.max(280, Math.round(svg.parentElement.clientWidth));
const small = W < 576;
const H = small ? 260 : 320, L = small ? 34 : 44, R = 16, T = 24, B = 40, max = 40;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('height', H);
svg.innerHTML = '';
tip.hidden = true;
const x = i => L + (i * (W - L - R - 8)) / (data.length - 1) + 4;
const y = v => T + (1 - v / max) * (H - T - B);
[0, 10, 20, 30, 40].forEach(v => {
el('line', { x1: L, x2: W - R, y1: y(v), y2: y(v), class: 'grid' });
const t = el('text', { x: L - 8, y: y(v) + 4, 'text-anchor': 'end', class: 'tick' }); t.textContent = v;
});
data.forEach((d, i) => {
if (small && data.length > 4 && i % 2 === 1 && i !== data.length - 1) return;
const t = el('text', { x: x(i), y: H - 12, 'text-anchor': 'middle', class: 'tick' }); t.textContent = d[0];
});
const pts = data.map((d, i) => [x(i), y(d[1])]);
let path = `M${pts[0][0]},${pts[0][1]}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1], [x1, y1] = pts[i], cx = (x0 + x1) / 2;
path += ` C${cx},${y0} ${cx},${y1} ${x1},${y1}`;
}
const defs = el('defs', {});
const g = el('linearGradient', { id: 'areaFill', x1: 0, y1: 0, x2: 0, y2: 1 }, defs);
el('stop', { offset: '0%', 'stop-color': 'var(--bs-primary)', 'stop-opacity': '.28' }, g);
el('stop', { offset: '100%', 'stop-color': 'var(--bs-primary)', 'stop-opacity': '0' }, g);
el('path', { d: `${path} L${pts[pts.length - 1][0]},${y(0)} L${pts[0][0]},${y(0)} Z`, fill: 'url(#areaFill)', class: 'area' });
const line = el('path', { d: path, class: 'line' });
if (animate && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
const len = line.getTotalLength();
line.style.strokeDasharray = len;
line.style.strokeDashoffset = len;
line.getBoundingClientRect();
line.style.transition = 'stroke-dashoffset 1.2s cubic-bezier(.4,0,.2,1)';
requestAnimationFrame(() => { line.style.strokeDashoffset = 0; });
}
const show = (c, d) => {
tip.innerHTML = `<small>${d[0]}</small>${d[1]}`;
tip.style.left = c.getAttribute('cx') + 'px';
tip.style.top = c.getAttribute('cy') + 'px';
tip.hidden = false;
};
data.forEach((d, i) => {
const hit = el('circle', { cx: pts[i][0], cy: pts[i][1], r: 22, class: 'hit', tabindex: 0, 'aria-label': `${d[0]}: ${d[1]} million` });
el('circle', { cx: pts[i][0], cy: pts[i][1], r: 5.5, class: 'dot' });
hit.addEventListener('mouseenter', () => show(hit, d));
hit.addEventListener('focus', () => show(hit, d));
hit.addEventListener('mouseleave', () => { tip.hidden = true; });
hit.addEventListener('blur', () => { tip.hidden = true; });
});
}
if (rangeBox) rangeBox.addEventListener('click', e => {
const b = e.target.closest('button[data-range]');
if (!b) return;
rangeBox.querySelectorAll('button').forEach(o => { const on = o === b; o.classList.toggle('active', on); o.setAttribute('aria-pressed', on); });
range = b.dataset.range.split(',').map(Number);
draw(true);
});
let lastW = 0, timer;
new ResizeObserver(() => {
const w = svg.parentElement.clientWidth;
if (w === lastW) return;
lastW = w; clearTimeout(timer); timer = setTimeout(() => draw(false), 80);
}).observe(svg.parentElement);
draw(true);
}
initLineChart();
Built on Bootstrap 5.3.8 with plain JavaScript — no jQuery. Supports light and dark mode via data-bs-theme.