UI Components Medium
Line Chart
A fully responsive SVG line chart with animated paths, multi-series support, smooth bezier curves, grid lines, axis labels, and interactive tooltips. Built with vanilla JS and inline SVG — no dependencies.
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;
--c0: #818cf8;
--c1: #34d399;
--c2: #f59e0b;
--c3: #f87171;
}
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: 16px;
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;
}
.chart-controls {
display: flex;
gap: 6px;
}
.ctrl-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 600;
padding: 5px 12px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: all .15s;
}
.ctrl-btn.active,
.ctrl-btn:hover {
border-color: var(--c0);
color: var(--c0);
}
.legend {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.78rem;
color: var(--text-muted);
cursor: pointer;
}
.legend-swatch {
width: 24px;
height: 3px;
border-radius: 2px;
}
.legend-item.muted {
opacity: 0.35;
}
.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;
}
/* Grid lines */
.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;
}
/* Lines */
.series-line {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.series-line-animated {
stroke-dasharray: 9999;
stroke-dashoffset: 9999;
animation: drawLine 1s ease forwards;
}
@keyframes drawLine {
to {
stroke-dashoffset: 0;
}
}
.series-dot {
r: 4;
stroke: var(--surface);
stroke-width: 2;
opacity: 0;
transition: opacity .15s;
cursor: pointer;
}
.series-dot.visible,
.chart-wrap:hover .series-dot {
opacity: 1;
}
/* Tooltip */
.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;
min-width: 120px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.tooltip-date {
font-weight: 700;
margin-bottom: 6px;
font-size: 0.8rem;
}
.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;
}
/* Crosshair */
.crosshair {
stroke: var(--text-muted);
stroke-width: 1;
stroke-dasharray: 4, 4;
pointer-events: none;
opacity: 0;
transition: opacity .1s;
}
.crosshair.visible {
opacity: 1;
}const SERIES = [
{
label: "Revenue",
color: "#818cf8",
data: [42, 58, 51, 67, 73, 89, 95, 88, 102, 115, 108, 127],
},
{
label: "Expenses",
color: "#f87171",
data: [30, 34, 29, 35, 40, 44, 48, 42, 50, 55, 52, 58],
},
{
label: "Profit",
color: "#34d399",
data: [12, 24, 22, 32, 33, 45, 47, 46, 52, 60, 56, 69],
},
];
const LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
let smoothMode = false;
let hidden = new Set();
const svg = document.getElementById("chartSvg");
const tooltip = document.getElementById("chartTooltip");
const wrap = document.getElementById("chartWrap");
const legend = document.getElementById("legend");
// Build legend
SERIES.forEach((s, i) => {
const item = document.createElement("div");
item.className = "legend-item";
item.dataset.idx = i;
item.innerHTML = `<span class="legend-swatch" style="background:${s.color}"></span><span>${s.label}</span>`;
item.addEventListener("click", () => {
hidden.has(i) ? hidden.delete(i) : hidden.add(i);
item.classList.toggle("muted", hidden.has(i));
draw();
});
legend.appendChild(item);
});
document.querySelectorAll(".ctrl-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".ctrl-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
smoothMode = btn.dataset.mode === "smooth";
draw();
});
});
function draw() {
const W = wrap.clientWidth - 32;
const H = Math.round(W * 0.45);
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.innerHTML = "";
const visibleSeries = SERIES.filter((_, i) => !hidden.has(i));
const allVals = visibleSeries.flatMap((s) => s.data);
if (!allVals.length) return;
const maxVal = Math.ceil(Math.max(...allVals) * 1.15);
const minVal = 0;
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const n = LABELS.length;
const xOf = (i) => PAD.left + (i / (n - 1)) * cW;
const yOf = (v) => PAD.top + cH - ((v - minVal) / (maxVal - minVal)) * cH;
// Grid
const ticks = 5;
for (let t = 0; t <= ticks; t++) {
const v = minVal + (maxVal - minVal) * (t / ticks);
const y = yOf(v);
const line = el("line", { class: "grid-line", x1: PAD.left, x2: PAD.left + cW, y1: y, y2: y });
svg.appendChild(line);
const lbl = el("text", {
class: "grid-label",
x: PAD.left - 6,
y: y + 3.5,
"text-anchor": "end",
});
lbl.textContent = Math.round(v) + "k";
svg.appendChild(lbl);
}
// X labels
LABELS.forEach((lbl, i) => {
const t = el("text", { class: "x-label", x: xOf(i), y: H - 6 });
t.textContent = lbl;
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);
// Series
SERIES.forEach((s, si) => {
if (hidden.has(si)) return;
const points = s.data.map((v, i) => [xOf(i), yOf(v)]);
const path = el("path", {
class: "series-line series-line-animated",
stroke: s.color,
d: smoothMode ? bezierPath(points) : linePath(points),
style: `animation-delay:${si * 0.12}s`,
});
svg.appendChild(path);
points.forEach(([px, py], i) => {
const dot = el("circle", {
class: "series-dot",
cx: px,
cy: py,
fill: s.color,
"data-si": si,
"data-i": i,
});
svg.appendChild(dot);
});
});
// Hover interaction
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">${LABELS[idx]}</div>`;
SERIES.forEach((s, si) => {
if (hidden.has(si)) return;
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]}k</span>
</div>`;
});
tooltip.innerHTML = html;
tooltip.hidden = false;
const tx = Math.min(x + 12, W - 160);
const ty = PAD.top;
tooltip.style.left = tx + "px";
tooltip.style.top = ty + "px";
});
svg.addEventListener("mouseleave", () => {
tooltip.hidden = true;
const ch = svg.querySelector("#crosshair");
if (ch) ch.classList.remove("visible");
});
}
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 linePath(pts) {
return pts
.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1))
.join(" ");
}
function bezierPath(pts) {
if (pts.length < 2) return linePath(pts);
let d = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const cp1x = pts[i][0] + (pts[i + 1][0] - pts[i][0]) * 0.4;
const cp1y = pts[i][1];
const cp2x = pts[i + 1][0] - (pts[i + 1][0] - pts[i][0]) * 0.4;
const cp2y = pts[i + 1][1];
d += ` C${cp1x.toFixed(1)} ${cp1y.toFixed(1)} ${cp2x.toFixed(1)} ${cp2y.toFixed(1)} ${pts[i + 1][0].toFixed(1)} ${pts[i + 1][1].toFixed(1)}`;
}
return d;
}
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>Line Chart</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-header">
<div>
<h1 class="chart-title">Revenue Over Time</h1>
<p class="chart-sub">Monthly figures, 2024</p>
</div>
<div class="chart-controls">
<button class="ctrl-btn active" data-mode="line">Line</button>
<button class="ctrl-btn" data-mode="smooth">Smooth</button>
</div>
</div>
<!-- Legend -->
<div class="legend" id="legend"></div>
<!-- Chart container -->
<div class="chart-wrap" id="chartWrap">
<svg id="chartSvg" class="chart-svg" aria-label="Line chart: Revenue Over Time"></svg>
<!-- Tooltip -->
<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: "Revenue",
color: "#818cf8",
data: [42, 58, 51, 67, 73, 89, 95, 88, 102, 115, 108, 127],
},
{ label: "Expenses", color: "#f87171", data: [30, 34, 29, 35, 40, 44, 48, 42, 50, 55, 52, 58] },
{ label: "Profit", color: "#34d399", data: [12, 24, 22, 32, 33, 45, 47, 46, 52, 60, 56, 69] },
];
const LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
function linePath(pts: [number, number][]) {
return pts
.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1))
.join(" ");
}
function bezierPath(pts: [number, number][]) {
if (pts.length < 2) return linePath(pts);
let d = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const cp1x = pts[i][0] + (pts[i + 1][0] - pts[i][0]) * 0.4;
const cp2x = pts[i + 1][0] - (pts[i + 1][0] - pts[i][0]) * 0.4;
d += ` C${cp1x.toFixed(1)} ${pts[i][1].toFixed(1)} ${cp2x.toFixed(1)} ${pts[i + 1][1].toFixed(1)} ${pts[i + 1][0].toFixed(1)} ${pts[i + 1][1].toFixed(1)}`;
}
return d;
}
export default function ChartLineRC() {
const wrapRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 600, h: 270 });
const [smooth, setSmooth] = useState(false);
const [hidden, setHidden] = useState<Set<number>>(new Set());
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 toggleHidden = (i: number) =>
setHidden((prev) => {
const n = new Set(prev);
n.has(i) ? n.delete(i) : n.add(i);
return n;
});
const { w: W, h: H } = dims;
const visible = SERIES.filter((_, i) => !hidden.has(i));
const allVals = visible.flatMap((s) => s.data);
const maxVal = allVals.length ? Math.ceil(Math.max(...allVals) * 1.15) : 100;
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const n = LABELS.length;
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 items-center justify-between mb-4 flex-wrap gap-2">
<div className="flex gap-2 flex-wrap">
{SERIES.map((s, i) => (
<button
key={s.label}
onClick={() => toggleHidden(i)}
className={`flex items-center gap-1.5 text-[11px] px-2 py-1 rounded border transition-opacity ${hidden.has(i) ? "opacity-30" : "opacity-100"} border-[#30363d] hover:border-[#8b949e]`}
>
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
<span className="text-[#8b949e]">{s.label}</span>
</button>
))}
</div>
<div className="flex gap-1">
{["Linear", "Smooth"].map((m) => (
<button
key={m}
onClick={() => setSmooth(m === "Smooth")}
className={`text-[11px] px-3 py-1 rounded border transition-colors ${(smooth ? "Smooth" : "Linear") === m ? "bg-[#818cf8]/20 border-[#818cf8] text-[#818cf8]" : "border-[#30363d] text-[#8b949e] hover:border-[#8b949e]"}`}
>
{m}
</button>
))}
</div>
</div>
<div className="relative">
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
className="w-full"
onMouseMove={handleMove}
onMouseLeave={() => setTooltip(null)}
>
{/* Grid */}
{Array.from({ length: 6 }, (_, t) => {
const v = (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}>
{Math.round(v)}k
</text>
</g>
);
})}
{LABELS.map((lbl, i) => (
<text key={lbl} x={xOf(i)} y={H - 6} textAnchor="middle" fill="#484f58" fontSize={10}>
{lbl}
</text>
))}
{tooltip && (
<line
x1={tooltip.x}
x2={tooltip.x}
y1={PAD.top}
y2={PAD.top + cH}
stroke="#8b949e"
strokeWidth={1}
strokeDasharray="4 2"
/>
)}
{/* Series */}
{SERIES.map((s, si) => {
if (hidden.has(si)) return null;
const pts: [number, number][] = s.data.map((v, i) => [xOf(i), yOf(v)]);
return (
<g key={s.label}>
<path
d={smooth ? bezierPath(pts) : linePath(pts)}
fill="none"
stroke={s.color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{pts.map(([px, py], i) => (
<circle
key={i}
cx={px}
cy={py}
r={tooltip?.idx === i ? 4 : 2.5}
fill={s.color}
opacity={tooltip?.idx === i ? 1 : 0.7}
/>
))}
</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-[140px]"
style={{ left: Math.min(tooltip.x + 12, W - 160), top: tooltip.y }}
>
<div className="text-[#8b949e] font-semibold mb-1.5">{LABELS[tooltip.idx]}</div>
{SERIES.map(
(s, si) =>
!hidden.has(si) && (
<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]}k</span>
</div>
)
)}
</div>
)}
</div>
</div>
</div>
);
}<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
const SERIES = [
{
label: "Revenue",
color: "#818cf8",
data: [42, 58, 51, 67, 73, 89, 95, 88, 102, 115, 108, 127],
},
{ label: "Expenses", color: "#f87171", data: [30, 34, 29, 35, 40, 44, 48, 42, 50, 55, 52, 58] },
{ label: "Profit", color: "#34d399", data: [12, 24, 22, 32, 33, 45, 47, 46, 52, 60, 56, 69] },
];
const LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
const wrapEl = ref(null);
const W = ref(600);
const H = ref(270);
const smooth = ref(false);
const hidden = ref(new Set());
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();
});
function toggleHidden(i) {
const n = new Set(hidden.value);
n.has(i) ? n.delete(i) : n.add(i);
hidden.value = n;
}
const n = LABELS.length;
const cW = computed(() => W.value - PAD.left - PAD.right);
const cH = computed(() => H.value - PAD.top - PAD.bottom);
const maxVal = computed(() => {
const visible = SERIES.filter((_, i) => !hidden.value.has(i));
const vals = visible.flatMap((s) => s.data);
return vals.length ? Math.ceil(Math.max(...vals) * 1.15) : 100;
});
function xOf(i) {
return PAD.left + (i / (n - 1)) * cW.value;
}
function yOf(v) {
return PAD.top + cH.value - (v / maxVal.value) * cH.value;
}
function linePath(pts) {
return pts
.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1))
.join(" ");
}
function bezierPath(pts) {
if (pts.length < 2) return linePath(pts);
let d = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const cp1x = pts[i][0] + (pts[i + 1][0] - pts[i][0]) * 0.4;
const cp2x = pts[i + 1][0] - (pts[i + 1][0] - pts[i][0]) * 0.4;
d += ` C${cp1x.toFixed(1)} ${pts[i][1].toFixed(1)} ${cp2x.toFixed(1)} ${pts[i + 1][1].toFixed(1)} ${pts[i + 1][0].toFixed(1)} ${pts[i + 1][1].toFixed(1)}`;
}
return d;
}
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) * (n - 1));
if (idx < 0 || idx >= n) {
tooltip.value = null;
return;
}
tooltip.value = { idx, x: xOf(idx), y: PAD.top };
}
const ticks = computed(() =>
Array.from({ length: 6 }, (_, t) => {
const v = (maxVal.value / 5) * t;
return { v, y: yOf(v), label: Math.round(v) + "k" };
})
);
const seriesData = computed(() =>
SERIES.map((s, si) => {
const pts = s.data.map((v, i) => [xOf(i), yOf(v)]);
const pathD = smooth.value ? bezierPath(pts) : linePath(pts);
return { ...s, si, pts, pathD };
})
);
</script>
<template>
<div class="page">
<div ref="wrapEl" class="wrap">
<div class="controls">
<div class="legend">
<button v-for="(s, i) in SERIES" :key="s.label" class="legend-btn"
:style="{ opacity: hidden.has(i) ? 0.3 : 1 }" @click="toggleHidden(i)">
<span class="legend-dot" :style="{ background: s.color }"></span>
<span class="legend-label">{{ s.label }}</span>
</button>
</div>
<div class="mode-btns">
<button class="mode-btn" :class="{ active: !smooth }" @click="smooth = false">Linear</button>
<button class="mode-btn" :class="{ active: smooth }" @click="smooth = true">Smooth</button>
</div>
</div>
<div class="chart-wrap">
<svg :width="W" :height="H" :viewBox="`0 0 ${W} ${H}`" class="chart-svg"
@mousemove="handleMove" @mouseleave="tooltip = null">
<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>
<text v-for="(lbl, i) in LABELS" :key="lbl" :x="xOf(i)" :y="H - 6" text-anchor="middle" fill="#484f58" font-size="10">{{ lbl }}</text>
<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 v-if="!hidden.has(s.si)">
<path :d="s.pathD" fill="none" :stroke="s.color" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle v-for="(pt, i) in s.pts" :key="i" :cx="pt[0]" :cy="pt[1]"
:r="tooltip && tooltip.idx === i ? 4 : 2.5"
:fill="s.color" :opacity="tooltip && tooltip.idx === i ? 1 : 0.7"/>
</g>
</template>
</svg>
<div v-if="tooltip" class="tooltip-box"
:style="{ left: Math.min(tooltip.x + 12, W - 160) + 'px', top: tooltip.y + 'px' }">
<div class="tooltip-date">{{ LABELS[tooltip.idx] }}</div>
<template v-for="(s, si) in SERIES" :key="s.label">
<div v-if="!hidden.has(si)" 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] }}k</span>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 800px; margin: 0 auto; }
.controls { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
.legend { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.legend-btn { display: flex; align-items: center; gap: 0.375rem; font-size: 11px; padding: 4px 8px; border-radius: 4px; border: 1px solid #30363d; background: none; cursor: pointer; transition: opacity 0.2s; }
.legend-btn:hover { border-color: #8b949e; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
.legend-label { color: #8b949e; }
.mode-btns { display: flex; gap: 4px; }
.mode-btn { font-size: 11px; padding: 4px 12px; border-radius: 4px; border: 1px solid #30363d; background: none; color: #8b949e; cursor: pointer; transition: all 0.2s; }
.mode-btn:hover { border-color: #8b949e; }
.mode-btn.active { background: rgba(129,140,248,0.2); border-color: #818cf8; color: #818cf8; }
.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: 140px; }
.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: "Revenue",
color: "#818cf8",
data: [42, 58, 51, 67, 73, 89, 95, 88, 102, 115, 108, 127],
},
{ label: "Expenses", color: "#f87171", data: [30, 34, 29, 35, 40, 44, 48, 42, 50, 55, 52, 58] },
{ label: "Profit", color: "#34d399", data: [12, 24, 22, 32, 33, 45, 47, 46, 52, 60, 56, 69] },
];
const LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
let wrapEl;
let W = 600;
let H = 270;
let smooth = false;
let hidden = new Set();
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();
});
function toggleHidden(i) {
const n = new Set(hidden);
n.has(i) ? n.delete(i) : n.add(i);
hidden = n;
}
$: n = LABELS.length;
$: cW = W - PAD.left - PAD.right;
$: cH = H - PAD.top - PAD.bottom;
$: maxVal = (() => {
const visible = SERIES.filter((_, i) => !hidden.has(i));
const vals = visible.flatMap((s) => s.data);
return vals.length ? Math.ceil(Math.max(...vals) * 1.15) : 100;
})();
function xOf(i) {
return PAD.left + (i / (n - 1)) * cW;
}
function yOf(v) {
return PAD.top + cH - (v / maxVal) * cH;
}
function linePath(pts) {
return pts
.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1))
.join(" ");
}
function bezierPath(pts) {
if (pts.length < 2) return linePath(pts);
let d = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const cp1x = pts[i][0] + (pts[i + 1][0] - pts[i][0]) * 0.4;
const cp2x = pts[i + 1][0] - (pts[i + 1][0] - pts[i][0]) * 0.4;
d += ` C${cp1x.toFixed(1)} ${pts[i][1].toFixed(1)} ${cp2x.toFixed(1)} ${pts[i + 1][1].toFixed(1)} ${pts[i + 1][0].toFixed(1)} ${pts[i + 1][1].toFixed(1)}`;
}
return d;
}
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 = (maxVal / 5) * t;
return { v, y: yOf(v), label: Math.round(v) + "k" };
});
$: seriesData = SERIES.map((s, si) => {
const pts = s.data.map((v, i) => [xOf(i), yOf(v)]);
const pathD = smooth ? bezierPath(pts) : linePath(pts);
return { ...s, si, pts, pathD };
});
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 800px; margin: 0 auto; }
.controls { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
.legend { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.legend-btn { display: flex; align-items: center; gap: 0.375rem; font-size: 11px; padding: 4px 8px; border-radius: 4px; border: 1px solid #30363d; background: none; cursor: pointer; transition: opacity 0.2s; }
.legend-btn:hover { border-color: #8b949e; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
.legend-label { color: #8b949e; }
.mode-btns { display: flex; gap: 4px; }
.mode-btn { font-size: 11px; padding: 4px 12px; border-radius: 4px; border: 1px solid #30363d; background: none; color: #8b949e; cursor: pointer; transition: all 0.2s; }
.mode-btn:hover { border-color: #8b949e; }
.mode-btn.active { background: rgba(129,140,248,0.2); border-color: #818cf8; color: #818cf8; }
.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: 140px; }
.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="controls">
<div class="legend">
{#each SERIES as s, i}
<button class="legend-btn" style="opacity:{hidden.has(i) ? 0.3 : 1}" on:click={() => toggleHidden(i)}>
<span class="legend-dot" style="background:{s.color}"></span>
<span class="legend-label">{s.label}</span>
</button>
{/each}
</div>
<div class="mode-btns">
<button class="mode-btn" class:active={!smooth} on:click={() => smooth = false}>Linear</button>
<button class="mode-btn" class:active={smooth} on:click={() => smooth = true}>Smooth</button>
</div>
</div>
<div class="chart-wrap">
<svg width={W} height={H} viewBox="0 0 {W} {H}" class="chart-svg"
on:mousemove={handleMove} on:mouseleave={() => tooltip = null}>
{#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 LABELS as lbl, i}
<text x={xOf(i)} y={H - 6} text-anchor="middle" fill="#484f58" font-size="10">{lbl}</text>
{/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}
{#if !hidden.has(s.si)}
<path d={s.pathD} fill="none" stroke={s.color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
{#each s.pts as pt, i}
<circle cx={pt[0]} cy={pt[1]}
r={tooltip && tooltip.idx === i ? 4 : 2.5}
fill={s.color} opacity={tooltip && tooltip.idx === i ? 1 : 0.7}/>
{/each}
{/if}
{/each}
</svg>
{#if tooltip}
<div class="tooltip-box" style="left:{Math.min(tooltip.x + 12, W - 160)}px; top:{tooltip.y}px;">
<div class="tooltip-date">{LABELS[tooltip.idx]}</div>
{#each SERIES as s, si}
{#if !hidden.has(si)}
<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]}k</span>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
</div>Features
- Multi-series — render multiple data lines with distinct colors
- Smooth bezier curves — cubic bezier interpolation for a polished look
- Animated path draw — stroke-dasharray animation on mount
- Interactive tooltip — follows mouse with formatted values and date labels
- Grid lines & axis labels — responsive X/Y grid with configurable tick count
- Responsive — redraws on window resize via ResizeObserver
How it works
- Data arrays are normalized to SVG viewport coordinates
cubicBezierPath()builds the smooth pathdattribute- Hover events use
getBoundingClientRect()to position a floating tooltip - The chart redraws automatically when the container resizes