UI Components Medium
Area Chart
A smooth area chart with gradient fill under the curve, multi-series stacking, animated draw-in, and interactive crosshair tooltip. Built with vanilla JS and SVG.
Open in Lab
MCP
vanilla-js svg react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 24px;
}
.chart-page {
max-width: 860px;
margin: 0 auto;
}
.chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 12px;
}
.chart-title {
font-size: 1.2rem;
font-weight: 700;
}
.chart-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 3px;
}
.legend {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.78rem;
color: var(--text-muted);
}
.legend-swatch {
width: 24px;
height: 3px;
border-radius: 2px;
}
.chart-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 16px 12px;
position: relative;
}
.chart-svg {
width: 100%;
display: block;
}
.area-path {
opacity: 0.25;
}
.line-path {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
}
.line-path-animated {
stroke-dasharray: 9999;
stroke-dashoffset: 9999;
animation: drawLine 1.1s ease forwards;
}
@keyframes drawLine {
to {
stroke-dashoffset: 0;
}
}
.grid-line {
stroke: var(--border);
stroke-width: 1;
}
.grid-label {
fill: var(--text-muted);
font-size: 10px;
font-family: inherit;
}
.x-label {
fill: var(--text-muted);
font-size: 10px;
font-family: inherit;
text-anchor: middle;
}
.crosshair {
stroke: var(--text-muted);
stroke-width: 1;
stroke-dasharray: 4, 4;
pointer-events: none;
opacity: 0;
transition: opacity .1s;
}
.crosshair.visible {
opacity: 1;
}
.chart-tooltip {
position: absolute;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.78rem;
pointer-events: none;
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
min-width: 130px;
}
.tooltip-date {
font-weight: 700;
margin-bottom: 4px;
}
.tooltip-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 3px;
}
.tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.tooltip-val {
margin-left: auto;
font-weight: 700;
}const SERIES = [
{
label: "Pageviews",
color: "#818cf8",
data: [3200, 2900, 3800, 4100, 3700, 4500, 4900, 4200, 5100, 4700, 5300, 5800, 5500, 6200],
},
{
label: "Sessions",
color: "#34d399",
data: [1800, 1600, 2100, 2300, 2000, 2600, 2900, 2500, 3100, 2800, 3200, 3500, 3300, 3800],
},
];
const DAYS = Array.from({ length: 14 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - 13 + i);
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
});
const PAD = { top: 20, right: 20, bottom: 36, left: 52 };
const svg = document.getElementById("chartSvg");
const tooltip = document.getElementById("chartTooltip");
const wrap = document.getElementById("chartWrap");
const legend = document.getElementById("legend");
SERIES.forEach((s) => {
const item = document.createElement("div");
item.className = "legend-item";
item.innerHTML = `<span class="legend-swatch" style="background:${s.color}"></span><span>${s.label}</span>`;
legend.appendChild(item);
});
function el(tag, attrs = {}) {
const e = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v));
return e;
}
function draw() {
const W = wrap.clientWidth - 32;
const H = Math.round(W * 0.45);
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.innerHTML = "";
const n = DAYS.length;
const allVals = SERIES.flatMap((s) => s.data);
const maxVal = Math.ceil(Math.max(...allVals) * 1.15);
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const xOf = (i) => PAD.left + (i / (n - 1)) * cW;
const yOf = (v) => PAD.top + cH - (v / maxVal) * cH;
// Defs (gradients)
const defs = el("defs");
SERIES.forEach((s, si) => {
const grad = el("linearGradient", { id: `grad${si}`, x1: "0", y1: "0", x2: "0", y2: "1" });
grad.innerHTML = `<stop offset="0%" stop-color="${s.color}" stop-opacity="0.6"/>
<stop offset="100%" stop-color="${s.color}" stop-opacity="0"/>`;
defs.appendChild(grad);
});
svg.appendChild(defs);
// Grid
for (let t = 0; t <= 5; t++) {
const v = Math.round((maxVal / 5) * t);
const y = yOf(v);
svg.appendChild(
el("line", { class: "grid-line", x1: PAD.left, x2: PAD.left + cW, y1: y, y2: y })
);
const lbl = el("text", {
class: "grid-label",
x: PAD.left - 6,
y: y + 3.5,
"text-anchor": "end",
});
lbl.textContent = v >= 1000 ? (v / 1000).toFixed(1) + "k" : v;
svg.appendChild(lbl);
}
DAYS.forEach((d, i) => {
if (i % 2 !== 0) return;
const t = el("text", { class: "x-label", x: xOf(i), y: H - 6 });
t.textContent = d;
svg.appendChild(t);
});
// Crosshair
const ch = el("line", {
class: "crosshair",
id: "crosshair",
x1: 0,
x2: 0,
y1: PAD.top,
y2: PAD.top + cH,
});
svg.appendChild(ch);
// Area + line
SERIES.forEach((s, si) => {
const pts = s.data.map((v, i) => [xOf(i), yOf(v)]);
const linePts = pts.map((p) => p.join(",")).join(" ");
const areaD = `M${xOf(0)},${PAD.top + cH} L${pts.map((p) => p.join(",")).join(" L")} L${xOf(n - 1)},${PAD.top + cH} Z`;
svg.appendChild(el("path", { class: "area-path", d: areaD, fill: `url(#grad${si})` }));
const line = el("polyline", {
class: `line-path line-path-animated`,
points: linePts,
stroke: s.color,
style: `animation-delay:${si * 0.15}s`,
});
svg.appendChild(line);
pts.forEach(([px, py], i) => {
const dot = el("circle", {
cx: px,
cy: py,
r: 4,
fill: s.color,
stroke: "var(--surface)",
"stroke-width": 2,
style: "opacity:0;transition:opacity .15s;cursor:pointer",
"data-si": si,
"data-i": i,
});
svg.appendChild(dot);
});
});
svg.addEventListener("mousemove", (e) => {
const rect = svg.getBoundingClientRect();
const mx = e.clientX - rect.left;
const idx = Math.round(((mx - PAD.left) / cW) * (n - 1));
if (idx < 0 || idx >= n) {
tooltip.hidden = true;
return;
}
const x = xOf(idx);
const ch = svg.querySelector("#crosshair");
if (ch) {
ch.setAttribute("x1", x);
ch.setAttribute("x2", x);
ch.classList.add("visible");
}
let html = `<div class="tooltip-date">${DAYS[idx]}</div>`;
SERIES.forEach((s) => {
html += `<div class="tooltip-row"><span class="tooltip-dot" style="background:${s.color}"></span><span>${s.label}</span><span class="tooltip-val">${s.data[idx].toLocaleString()}</span></div>`;
});
tooltip.innerHTML = html;
tooltip.hidden = false;
tooltip.style.left = Math.min(x + 12, W - 160) + "px";
tooltip.style.top = PAD.top + "px";
});
svg.addEventListener("mouseleave", () => {
tooltip.hidden = true;
svg.querySelector("#crosshair")?.classList.remove("visible");
});
}
const ro = new ResizeObserver(draw);
ro.observe(wrap);
draw();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Area Chart</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-header">
<div>
<h1 class="chart-title">Pageviews vs Sessions</h1>
<p class="chart-sub">Daily, last 14 days</p>
</div>
</div>
<div class="legend" id="legend"></div>
<div class="chart-wrap" id="chartWrap">
<svg id="chartSvg" class="chart-svg" aria-label="Area chart"></svg>
<div class="chart-tooltip" id="chartTooltip" hidden></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect, useCallback } from "react";
const SERIES = [
{
label: "Pageviews",
color: "#818cf8",
data: [3200, 2900, 3800, 4100, 3700, 4500, 4900, 4200, 5100, 4700, 5300, 5800, 5500, 6200],
},
{
label: "Sessions",
color: "#34d399",
data: [1800, 1600, 2100, 2300, 2000, 2600, 2900, 2500, 3100, 2800, 3200, 3500, 3300, 3800],
},
];
const DAYS = Array.from({ length: 14 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - 13 + i);
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
});
const PAD = { top: 20, right: 20, bottom: 36, left: 52 };
export default function ChartAreaRC() {
const wrapRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 600, h: 270 });
const [tooltip, setTooltip] = useState<{ idx: number; x: number; y: number } | null>(null);
useEffect(() => {
const ro = new ResizeObserver(() => {
if (!wrapRef.current) return;
const w = wrapRef.current.clientWidth - 32;
setDims({ w, h: Math.round(w * 0.45) });
});
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const { w: W, h: H } = dims;
const n = DAYS.length;
const allVals = SERIES.flatMap((s) => s.data);
const maxVal = Math.ceil(Math.max(...allVals) * 1.15);
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const xOf = (i: number) => PAD.left + (i / (n - 1)) * cW;
const yOf = (v: number) => PAD.top + cH - (v / maxVal) * cH;
const handleMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (W / rect.width);
const idx = Math.round(((mx - PAD.left) / cW) * (n - 1));
if (idx < 0 || idx >= n) {
setTooltip(null);
return;
}
setTooltip({ idx, x: xOf(idx), y: PAD.top });
},
[W, cW, n, xOf]
);
return (
<div className="min-h-screen bg-[#0d1117] p-6">
<div ref={wrapRef} className="w-full max-w-[800px] mx-auto">
<div className="flex gap-4 mb-4">
{SERIES.map((s) => (
<div key={s.label} className="flex items-center gap-1.5 text-[12px]">
<span className="w-3 h-0.5 rounded-full" style={{ background: s.color }} />
<span className="text-[#8b949e]">{s.label}</span>
</div>
))}
</div>
<div className="relative">
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
className="w-full"
onMouseMove={handleMove}
onMouseLeave={() => setTooltip(null)}
>
<defs>
{SERIES.map((s, si) => (
<linearGradient key={si} id={`agrad${si}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity={0.5} />
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
))}
</defs>
{/* Grid */}
{Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxVal / 5) * t);
const y = yOf(v);
return (
<g key={t}>
<line
x1={PAD.left}
x2={PAD.left + cW}
y1={y}
y2={y}
stroke="#21262d"
strokeWidth={1}
/>
<text x={PAD.left - 6} y={y + 3.5} textAnchor="end" fill="#484f58" fontSize={10}>
{v >= 1000 ? (v / 1000).toFixed(1) + "k" : v}
</text>
</g>
);
})}
{DAYS.map(
(d, i) =>
i % 2 === 0 && (
<text
key={d}
x={xOf(i)}
y={H - 6}
textAnchor="middle"
fill="#484f58"
fontSize={10}
>
{d}
</text>
)
)}
{tooltip && (
<line
x1={tooltip.x}
x2={tooltip.x}
y1={PAD.top}
y2={PAD.top + cH}
stroke="#8b949e"
strokeWidth={1}
strokeDasharray="4 2"
/>
)}
{SERIES.map((s, si) => {
const pts = s.data.map((v, i): [number, number] => [xOf(i), yOf(v)]);
const ptsStr = pts.map((p) => p.join(",")).join(" ");
const areaD = `M${xOf(0)},${PAD.top + cH} L${ptsStr} L${xOf(n - 1)},${PAD.top + cH} Z`;
return (
<g key={s.label}>
<path d={areaD} fill={`url(#agrad${si})`} />
<polyline
points={ptsStr}
fill="none"
stroke={s.color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{tooltip && (
<circle
cx={pts[tooltip.idx][0]}
cy={pts[tooltip.idx][1]}
r={4}
fill={s.color}
stroke="#0d1117"
strokeWidth={2}
/>
)}
</g>
);
})}
</svg>
{tooltip && (
<div
className="absolute pointer-events-none bg-[#161b22] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] shadow-lg min-w-[150px]"
style={{ left: Math.min(tooltip.x + 12, W - 170), top: tooltip.y }}
>
<div className="text-[#8b949e] font-semibold mb-1.5">{DAYS[tooltip.idx]}</div>
{SERIES.map((s) => (
<div key={s.label} className="flex items-center gap-2 py-0.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: s.color }}
/>
<span className="text-[#8b949e] flex-1">{s.label}</span>
<span className="text-[#e6edf3] font-bold">
{s.data[tooltip.idx].toLocaleString()}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
const SERIES = [
{
label: "Pageviews",
color: "#818cf8",
data: [3200, 2900, 3800, 4100, 3700, 4500, 4900, 4200, 5100, 4700, 5300, 5800, 5500, 6200],
},
{
label: "Sessions",
color: "#34d399",
data: [1800, 1600, 2100, 2300, 2000, 2600, 2900, 2500, 3100, 2800, 3200, 3500, 3300, 3800],
},
];
const DAYS = Array.from({ length: 14 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - 13 + i);
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
});
const PAD = { top: 20, right: 20, bottom: 36, left: 52 };
const wrapEl = ref(null);
const W = ref(600);
const H = ref(270);
const tooltip = ref(null);
let ro = null;
onMounted(() => {
ro = new ResizeObserver(() => {
if (!wrapEl.value) return;
W.value = wrapEl.value.clientWidth - 32;
H.value = Math.round(W.value * 0.45);
});
if (wrapEl.value) ro.observe(wrapEl.value);
});
onUnmounted(() => {
if (ro) ro.disconnect();
});
const totalDays = DAYS.length;
const allVals = SERIES.flatMap((s) => s.data);
const maxVal = computed(() => Math.ceil(Math.max(...allVals) * 1.15));
const cW = computed(() => W.value - PAD.left - PAD.right);
const cH = computed(() => H.value - PAD.top - PAD.bottom);
function xOf(i) {
return PAD.left + (i / (totalDays - 1)) * cW.value;
}
function yOf(v) {
return PAD.top + cH.value - (v / maxVal.value) * cH.value;
}
function handleMove(e) {
const rect = e.currentTarget.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (W.value / rect.width);
const idx = Math.round(((mx - PAD.left) / cW.value) * (totalDays - 1));
if (idx < 0 || idx >= totalDays) {
tooltip.value = null;
return;
}
tooltip.value = { idx, x: xOf(idx), y: PAD.top };
}
const ticks = computed(() =>
Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxVal.value / 5) * t);
return { v, y: yOf(v), label: v >= 1000 ? (v / 1000).toFixed(1) + "k" : String(v) };
})
);
const seriesData = computed(() =>
SERIES.map((s, si) => {
const pts = s.data.map((v, i) => [xOf(i), yOf(v)]);
const ptsStr = pts.map((p) => p.join(",")).join(" ");
const baseY = PAD.top + cH.value;
const lineStr = pts.map((p) => p[0] + "," + p[1]).join(" ");
const areaD =
"M" + xOf(0) + "," + baseY + " L" + lineStr + " L" + xOf(totalDays - 1) + "," + baseY + " Z";
const gradId = "agrad" + si;
const gradFill = "url(#" + gradId + ")";
return {
label: s.label,
color: s.color,
si: si,
pts: pts,
ptsStr: ptsStr,
areaD: areaD,
gradId: gradId,
gradFill: gradFill,
};
})
);
</script>
<template>
<div class="page">
<div ref="wrapEl" class="wrap">
<div class="legend">
<div v-for="s in SERIES" :key="s.label" class="legend-item">
<span class="legend-dot" :style="{ background: s.color }"></span>
<span class="legend-label">{{ s.label }}</span>
</div>
</div>
<div class="chart-wrap">
<svg :width="W" :height="H" :viewBox="'0 0 ' + W + ' ' + H" class="chart-svg"
@mousemove="handleMove" @mouseleave="tooltip = null">
<defs>
<linearGradient v-for="(s, si) in SERIES" :key="si" :id="'agrad' + si" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="s.color" stop-opacity="0.5"/>
<stop offset="100%" :stop-color="s.color" stop-opacity="0"/>
</linearGradient>
</defs>
<g v-for="tick in ticks" :key="tick.v">
<line :x1="PAD.left" :x2="PAD.left+cW" :y1="tick.y" :y2="tick.y" stroke="#21262d" stroke-width="1"/>
<text :x="PAD.left-6" :y="tick.y+3.5" text-anchor="end" fill="#484f58" font-size="10">{{ tick.label }}</text>
</g>
<template v-for="(d, i) in DAYS" :key="d">
<text v-if="i % 2 === 0" :x="xOf(i)" :y="H-6" text-anchor="middle" fill="#484f58" font-size="10">{{ d }}</text>
</template>
<line v-if="tooltip" :x1="tooltip.x" :x2="tooltip.x" :y1="PAD.top" :y2="PAD.top+cH" stroke="#8b949e" stroke-width="1" stroke-dasharray="4 2"/>
<template v-for="s in seriesData" :key="s.label">
<g>
<path :d="s.areaD" :fill="s.gradFill"/>
<polyline :points="s.ptsStr" fill="none" :stroke="s.color" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle v-if="tooltip" :cx="s.pts[tooltip.idx][0]" :cy="s.pts[tooltip.idx][1]" r="4" :fill="s.color" stroke="#0d1117" stroke-width="2"/>
</g>
</template>
</svg>
<div v-if="tooltip" class="tooltip-box" :style="{ left: Math.min(tooltip.x+12, W-170)+'px', top: tooltip.y+'px' }">
<div class="tooltip-date">{{ DAYS[tooltip.idx] }}</div>
<div v-for="s in SERIES" :key="s.label" class="tooltip-row">
<span class="tooltip-dot" :style="{ background: s.color }"></span>
<span class="tooltip-label">{{ s.label }}</span>
<span class="tooltip-val">{{ s.data[tooltip.idx].toLocaleString() }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; }
.wrap { width: 100%; max-width: 800px; margin: 0 auto; }
.legend { display: flex; gap: 1rem; margin-bottom: 1rem; }
.legend-item { display: flex; align-items: center; gap: 0.375rem; font-size: 12px; }
.legend-dot { width: 12px; height: 2px; border-radius: 999px; }
.legend-label { color: #8b949e; }
.chart-wrap { position: relative; }
.chart-svg { width: 100%; }
.tooltip-box {
position: absolute; pointer-events: none; background: #161b22;
border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem;
font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); min-width: 150px;
}
.tooltip-date { color: #8b949e; font-weight: 600; margin-bottom: 0.375rem; }
.tooltip-row { display: flex; align-items: center; gap: 0.5rem; padding: 2px 0; }
.tooltip-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.tooltip-label { color: #8b949e; flex: 1; }
.tooltip-val { color: #e6edf3; font-weight: 700; }
</style><script>
import { onMount, onDestroy } from "svelte";
const SERIES = [
{
label: "Pageviews",
color: "#818cf8",
data: [3200, 2900, 3800, 4100, 3700, 4500, 4900, 4200, 5100, 4700, 5300, 5800, 5500, 6200],
},
{
label: "Sessions",
color: "#34d399",
data: [1800, 1600, 2100, 2300, 2000, 2600, 2900, 2500, 3100, 2800, 3200, 3500, 3300, 3800],
},
];
const DAYS = Array.from({ length: 14 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - 13 + i);
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
});
const PAD = { top: 20, right: 20, bottom: 36, left: 52 };
let wrapEl;
let W = 600;
let H = 270;
let tooltip = null;
let ro;
onMount(() => {
ro = new ResizeObserver(() => {
if (!wrapEl) return;
W = wrapEl.clientWidth - 32;
H = Math.round(W * 0.45);
});
if (wrapEl) ro.observe(wrapEl);
});
onDestroy(() => {
if (ro) ro.disconnect();
});
$: n = DAYS.length;
$: allVals = SERIES.flatMap((s) => s.data);
$: maxVal = Math.ceil(Math.max(...allVals) * 1.15);
$: cW = W - PAD.left - PAD.right;
$: cH = H - PAD.top - PAD.bottom;
function xOf(i) {
return PAD.left + (i / (n - 1)) * cW;
}
function yOf(v) {
return PAD.top + cH - (v / maxVal) * cH;
}
function handleMove(e) {
const rect = e.currentTarget.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (W / rect.width);
const idx = Math.round(((mx - PAD.left) / cW) * (n - 1));
if (idx < 0 || idx >= n) {
tooltip = null;
return;
}
tooltip = { idx, x: xOf(idx), y: PAD.top };
}
$: ticks = Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxVal / 5) * t);
return { v, y: yOf(v), label: v >= 1000 ? (v / 1000).toFixed(1) + "k" : String(v) };
});
$: seriesData = SERIES.map((s, si) => {
const pts = s.data.map((v, i) => [xOf(i), yOf(v)]);
const ptsStr = pts.map((p) => p.join(",")).join(" ");
const areaD = `M${xOf(0)},${PAD.top + cH} L${ptsStr} L${xOf(n - 1)},${PAD.top + cH} Z`;
return { ...s, si, pts, ptsStr, areaD };
});
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; }
.wrap { width: 100%; max-width: 800px; margin: 0 auto; }
.legend { display: flex; gap: 1rem; margin-bottom: 1rem; }
.legend-item { display: flex; align-items: center; gap: 0.375rem; font-size: 12px; }
.legend-dot { width: 12px; height: 2px; border-radius: 999px; }
.legend-label { color: #8b949e; }
.chart-wrap { position: relative; }
svg { width: 100%; }
.tooltip-box {
position: absolute;
pointer-events: none;
background: #161b22;
border: 1px solid #30363d;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 12px;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3);
min-width: 150px;
}
.tooltip-date { color: #8b949e; font-weight: 600; margin-bottom: 0.375rem; }
.tooltip-row { display: flex; align-items: center; gap: 0.5rem; padding: 2px 0; }
.tooltip-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.tooltip-label { color: #8b949e; flex: 1; }
.tooltip-val { color: #e6edf3; font-weight: 700; }
</style>
<div class="page">
<div class="wrap" bind:this={wrapEl}>
<div class="legend">
{#each SERIES as s}
<div class="legend-item">
<span class="legend-dot" style="background:{s.color}"></span>
<span class="legend-label">{s.label}</span>
</div>
{/each}
</div>
<div class="chart-wrap">
<svg width={W} height={H} viewBox="0 0 {W} {H}"
on:mousemove={handleMove} on:mouseleave={() => tooltip = null}>
<defs>
{#each SERIES as s, si}
<linearGradient id="agrad{si}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={s.color} stop-opacity="0.5"/>
<stop offset="100%" stop-color={s.color} stop-opacity="0"/>
</linearGradient>
{/each}
</defs>
{#each ticks as tick}
<line x1={PAD.left} x2={PAD.left+cW} y1={tick.y} y2={tick.y} stroke="#21262d" stroke-width="1"/>
<text x={PAD.left-6} y={tick.y+3.5} text-anchor="end" fill="#484f58" font-size="10">{tick.label}</text>
{/each}
{#each DAYS as d, i}
{#if i % 2 === 0}
<text x={xOf(i)} y={H-6} text-anchor="middle" fill="#484f58" font-size="10">{d}</text>
{/if}
{/each}
{#if tooltip}
<line x1={tooltip.x} x2={tooltip.x} y1={PAD.top} y2={PAD.top+cH} stroke="#8b949e" stroke-width="1" stroke-dasharray="4 2"/>
{/if}
{#each seriesData as s}
<path d={s.areaD} fill="url(#agrad{s.si})"/>
<polyline points={s.ptsStr} fill="none" stroke={s.color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
{#if tooltip}
<circle cx={s.pts[tooltip.idx][0]} cy={s.pts[tooltip.idx][1]} r="4" fill={s.color} stroke="#0d1117" stroke-width="2"/>
{/if}
{/each}
</svg>
{#if tooltip}
<div class="tooltip-box" style="left:{Math.min(tooltip.x+12, W-170)}px; top:{tooltip.y}px;">
<div class="tooltip-date">{DAYS[tooltip.idx]}</div>
{#each SERIES as s}
<div class="tooltip-row">
<span class="tooltip-dot" style="background:{s.color}"></span>
<span class="tooltip-label">{s.label}</span>
<span class="tooltip-val">{s.data[tooltip.idx].toLocaleString()}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>Features
- Gradient fill — SVG
<linearGradient>from accent color to transparent - Multi-series — stacked or overlapping area layers
- Crosshair tooltip — vertical line snaps to nearest data point
- Smooth bezier — cubic bezier interpolation for organic curves
- Animated path — draw-in animation on mount
- Responsive — ResizeObserver-driven redraw
How it works
- Each series produces a closed SVG polygon path (back along the baseline)
- A
<defs>gradient is defined per series and referenced byfill - Mouse move events find the nearest X-index and render a crosshair + tooltip