{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "line-chart",
  "type": "registry:block",
  "title": "Line Chart",
  "description": "A line chart that draws itself in on scroll, with a gradient area that unmasks behind the drawing head and a headline number that counts along with it. On hover a crosshair springs to the pointer, its dot rides the real monotone curve rather than snapping to points, and a tooltip card follows on its own spring with every series and the change against the previous period.",
  "docs": "Line Chart was added to public/vividsites/line-chart.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/line-chart",
  "categories": [
    "ui",
    "data-display"
  ],
  "files": [
    {
      "path": "components/vividsites/line-chart.html",
      "type": "registry:file",
      "target": "public/vividsites/line-chart.html",
      "content": "<!--\n  vs-line-chart: a line chart that draws itself in when it scrolls into view. The accent line\n  unrolls on an eased curve while its gradient area unmasks left to right behind the head, the\n  headline number counts along with the head, and the end point pops with a pulse ring. On hover a\n  crosshair follows the pointer on a spring and its dot rides the real curve (monotone cubic, the\n  same maths that draws the path, so it never snaps to points); everything right of the crosshair\n  dims, and a tooltip card follows on its own spring, flips side at the edges, and shows the\n  interpolated date, every visible series and the change against the comparison series.\n\n  Markup:\n    <figure class=\"vs-line-chart\" data-vs-line-chart-curve=\"smooth\" data-vs-line-chart-area=\"on\">\n      <figcaption class=\"vs-line-chart-head\">\n        <div class=\"vs-line-chart-title\">Weekly active readers</div>\n        <div class=\"vs-line-chart-kpi\"><span class=\"vs-line-chart-value\">84,210</span>\n          <span class=\"vs-line-chart-delta\" data-dir=\"up\">+6.2%</span><span class=\"vs-line-chart-vs\">vs previous period</span></div>\n      </figcaption>\n      <script type=\"application/json\">{ \"start\": \"2026-03-30\", \"step\": 7, \"series\": [ … ] }</script>\n    </figure>\n  JSON:\n    start / step      ISO date of the first point and days between points (dates are interpolated\n                      to the day on hover). Or give \"labels\": [\"W1\", …] for categorical points.\n    series            [{ \"name\", \"values\": [], \"style\": \"accent\" | \"ink\" | \"dashed\", \"compare\": true }]\n                      The first series is the main one: accent line, area, end dot, live headline.\n                      The series marked \"compare\" is what the tooltip's delta is measured against.\n    locale, prefix, suffix, decimals   number formatting (Intl); \"label\": the chart's accessible name.\n  Attributes (live; change them any time):\n    data-vs-line-chart-curve   smooth (monotone cubic, never overshoots) | linear | step\n    data-vs-line-chart-area    on | off\n    data-vs-line-chart-theme   dark (default) | light\n  Variables:\n    --vs-line-chart-accent   main line, area and dot       (#4f8cff)\n    --vs-line-chart-ink      text and secondary lines      (#e8edf8)\n    --vs-line-chart-surface  card background (set transparent to go bare)\n    --vs-line-chart-up / -down   delta colours\n    --vs-line-chart-radius   card radius                   (22px)\n    --vs-line-chart-height   plot height                   (360px)\n    --vs-line-chart-draw     draw-in duration              (1800ms)\n    --vs-line-chart-font / --vs-line-chart-mono   values face / axis-label face (inherit / system mono)\n  JS: el.vsLineChart.replay() redraws; el.vsLineChart.undraw(ms) rewinds it; el.vsLineChart.focusPoint(i) moves the crosshair to point i.\n  Events: \"vs-line-chart-point\" bubbles with detail {index, date, values} while scrubbing.\n  Keyboard: Tab to the plot, Left / Right step through points (Shift: 4), Home / End, Esc.\n  Touch: drag across the plot to scrub; the page still scrolls vertically.\n  Accessibility: the plot is a labelled group with a visually hidden data table, and each keyboard\n  step is announced through a polite live region. The tooltip is aria-hidden.\n  Reduced motion: the chart renders drawn; the crosshair and tooltip move without springs.\n-->\n<style>\n  .vs-line-chart {\n    --vs-line-chart-accent: #4f8cff;\n    --vs-line-chart-ink: #e8edf8;\n    --vs-line-chart-surface: linear-gradient(180deg, #121827, #0c111c 60%);\n    --vs-line-chart-ring: rgba(255,255,255,.085);\n    --vs-line-chart-dot-ring: #0b0f1a;\n    --vs-line-chart-tip: rgba(16,21,34,.84);\n    --vs-line-chart-up: #5ad59b;\n    --vs-line-chart-down: #ff7d8a;\n    --vs-line-chart-radius: 22px;\n    --vs-line-chart-height: 360px;\n    --vs-line-chart-draw: 1800ms;\n    --vs-line-chart-font: inherit;\n    --vs-line-chart-mono: ui-monospace, \"SF Mono\", Menlo, monospace;\n    --vs-line-chart-mute: color-mix(in srgb, var(--vs-line-chart-ink) 52%, transparent);\n    --vs-line-chart-faint: color-mix(in srgb, var(--vs-line-chart-ink) 38%, transparent);\n    --vs-line-chart-grid: color-mix(in srgb, var(--vs-line-chart-ink) 7.5%, transparent);\n    --vs-line-chart-pop: linear(0, .046, .165, .328, .509, .688, .85, .985, 1.087, 1.156, 1.194, 1.205, 1.196, 1.171, 1.138, 1.101, 1.064, 1.031, 1.003, .982, .968, .96, .958, .96, .965, .972, .979, .987, .994, .999, 1);\n    position: relative; box-sizing: border-box; margin: 0; padding: 26px 28px 18px;\n    font-family: var(--vs-line-chart-font); color: var(--vs-line-chart-ink);\n    font-variant-numeric: tabular-nums; font-feature-settings: \"tnum\" 1;\n    background: var(--vs-line-chart-surface); border-radius: var(--vs-line-chart-radius);\n    box-shadow: 0 0 0 .5px var(--vs-line-chart-ring), inset 0 1px 0 rgba(255,255,255,.06),\n      0 2px 6px rgba(0,0,0,.22), 0 24px 60px -24px rgba(0,0,0,.55), 0 60px 120px -60px rgba(0,0,0,.5);\n    -webkit-font-smoothing: antialiased;\n  }\n  .vs-line-chart[data-vs-line-chart-theme=\"light\"] {\n    --vs-line-chart-ink: #101828;\n    --vs-line-chart-surface: #ffffff;\n    --vs-line-chart-ring: rgba(16,24,40,.1);\n    --vs-line-chart-dot-ring: #ffffff;\n    --vs-line-chart-tip: rgba(255,255,255,.9);\n    --vs-line-chart-up: #11804c;\n    --vs-line-chart-down: #c8323f;\n    --vs-line-chart-grid: color-mix(in srgb, var(--vs-line-chart-ink) 8%, transparent);\n    box-shadow: 0 0 0 .5px var(--vs-line-chart-ring), inset 0 1px 0 #fff,\n      0 1px 2px rgba(16,24,40,.05), 0 18px 44px -20px rgba(16,24,40,.22), 0 50px 100px -50px rgba(16,24,40,.2);\n  }\n  .vs-line-chart *, .vs-line-chart *::before { box-sizing: border-box; }\n\n  /* ── header ── */\n  .vs-line-chart-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 14px 28px; }\n  .vs-line-chart-title { font-size: 13px; font-weight: 500; letter-spacing: -.005em; color: var(--vs-line-chart-mute); margin-bottom: 8px; }\n  .vs-line-chart-kpi { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; }\n  .vs-line-chart-value { font-size: 46px; line-height: 1; font-weight: 400; letter-spacing: -.035em; }\n  .vs-line-chart-delta {\n    display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px 0 7px; border-radius: 999px;\n    font-size: 12.5px; font-weight: 500; color: var(--vs-line-chart-up);\n    background: color-mix(in srgb, var(--vs-line-chart-up) 13%, transparent);\n    box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-line-chart-up) 30%, transparent);\n    transform: scale(.4); opacity: 0; transition: transform .7s var(--vs-line-chart-pop), opacity .25s;\n  }\n  .vs-line-chart-delta::before { content: \"\"; width: 0; height: 0; border: 4px solid transparent; border-bottom: 5px solid currentColor; border-top: 0; margin-top: -1px; }\n  .vs-line-chart-delta[data-dir=\"down\"] { color: var(--vs-line-chart-down); background: color-mix(in srgb, var(--vs-line-chart-down) 13%, transparent); box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-line-chart-down) 30%, transparent); }\n  .vs-line-chart-delta[data-dir=\"down\"]::before { border-bottom: 0; border-top: 5px solid currentColor; margin-top: 1px; }\n  .vs-line-chart.is-drawn .vs-line-chart-delta { transform: none; opacity: 1; }\n  .vs-line-chart-vs { font-size: 12.5px; color: var(--vs-line-chart-mute); opacity: 0; transform: translateX(-6px); transition: opacity .5s .08s, transform .6s .08s cubic-bezier(.16,1,.3,1); }\n  .vs-line-chart.is-drawn .vs-line-chart-vs { opacity: 1; transform: none; }\n\n  .vs-line-chart-legend { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 -8px 2px 0; }\n  .vs-line-chart-legend button {\n    display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; border: 0; border-radius: 9px;\n    font: 500 12.5px/1 var(--vs-line-chart-font); color: var(--vs-line-chart-ink); background: transparent; cursor: pointer;\n    -webkit-tap-highlight-color: transparent; transition: background .2s, color .2s, transform .35s var(--vs-line-chart-pop);\n  }\n  .vs-line-chart-legend button:hover { background: color-mix(in srgb, var(--vs-line-chart-ink) 6%, transparent); }\n  .vs-line-chart-legend button:active { transform: scale(.95); transition-duration: .1s; }\n  .vs-line-chart-legend button:focus-visible { outline: 1.5px solid var(--vs-line-chart-accent); outline-offset: 1px; }\n  .vs-line-chart-legend button[aria-pressed=\"false\"] { color: var(--vs-line-chart-faint); }\n  .vs-line-chart-legend button[aria-pressed=\"false\"] .vs-line-chart-key { opacity: .35; transform: scaleX(.55); }\n  .vs-line-chart-key { width: 16px; height: 2.5px; border-radius: 2px; background: var(--k); transition: opacity .3s, transform .5s var(--vs-line-chart-pop); }\n  .vs-line-chart-key[data-s=\"dashed\"] { background: repeating-linear-gradient(90deg, var(--k) 0 4px, transparent 4px 7px); height: 2px; }\n\n  /* ── plot ── */\n  .vs-line-chart-plot {\n    position: relative; height: var(--vs-line-chart-height); margin-top: 22px; border-radius: 12px; outline: none;\n    touch-action: pan-y; cursor: crosshair; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n  }\n  .vs-line-chart-plot:focus-visible { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--vs-line-chart-accent) 70%, transparent); }\n  .vs-line-chart-plot > svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; display: block; }\n  .vs-line-chart-gl { stroke: var(--vs-line-chart-grid); stroke-width: 1; shape-rendering: crispEdges; }\n  .vs-line-chart-gl.is-base { stroke: color-mix(in srgb, var(--vs-line-chart-ink) 16%, transparent); }\n  .vs-line-chart-plot text { font-family: var(--vs-line-chart-mono); font-size: 10.5px; letter-spacing: .04em; fill: var(--vs-line-chart-faint); text-transform: uppercase; }\n  .vs-line-chart-stop-a { stop-color: var(--vs-line-chart-accent); stop-opacity: .34; }\n  .vs-line-chart-stop-b { stop-color: var(--vs-line-chart-accent); stop-opacity: .1; }\n  .vs-line-chart-stop-c { stop-color: var(--vs-line-chart-accent); stop-opacity: 0; }\n  .vs-line-chart-areas { transition: opacity .5s cubic-bezier(.2,0,0,1); }\n  .vs-line-chart[data-vs-line-chart-area=\"off\"] .vs-line-chart-areas { opacity: 0; }\n  .vs-line-chart-line { fill: none; stroke-linecap: round; stroke-linejoin: round; }\n  .vs-line-chart-line[data-s=\"accent\"] { stroke: var(--vs-line-chart-accent); stroke-width: 2.25; filter: drop-shadow(0 6px 12px color-mix(in srgb, var(--vs-line-chart-accent) 40%, transparent)); }\n  .vs-line-chart-line[data-s=\"ink\"] { stroke: color-mix(in srgb, var(--vs-line-chart-ink) 62%, transparent); stroke-width: 1.5; }\n  .vs-line-chart-line[data-s=\"dashed\"] { stroke: var(--vs-line-chart-faint); stroke-width: 1.5; stroke-dasharray: 3 5; }\n  .vs-line-chart-cross { stroke: color-mix(in srgb, var(--vs-line-chart-ink) 30%, transparent); stroke-width: 1; shape-rendering: crispEdges; }\n  .vs-line-chart-hover { opacity: 0; transition: opacity .22s cubic-bezier(.2,0,0,1); }\n  .vs-line-chart.is-hover .vs-line-chart-hover { opacity: 1; }\n  .vs-line-chart-dot { stroke: var(--vs-line-chart-dot-ring); stroke-width: 2.5; }\n  .vs-line-chart-dot[data-s=\"accent\"] { fill: var(--vs-line-chart-accent); }\n  .vs-line-chart-dot[data-s=\"ink\"] { fill: color-mix(in srgb, var(--vs-line-chart-ink) 80%, var(--vs-line-chart-dot-ring)); }\n  .vs-line-chart-dot[data-s=\"dashed\"] { fill: var(--vs-line-chart-dot-ring); stroke: var(--vs-line-chart-faint); stroke-width: 1.5; }\n  .vs-line-chart-halo { fill: var(--vs-line-chart-accent); opacity: .18; }\n  .vs-line-chart-head-dot { fill: var(--vs-line-chart-accent); opacity: 0; }\n  .vs-line-chart-end { transform: scale(0); transform-box: fill-box; transform-origin: center; transition: transform .25s cubic-bezier(.4,0,1,1); }\n  .vs-line-chart.is-drawn .vs-line-chart-end { transform: scale(1); transition: transform .8s var(--vs-line-chart-pop); }\n  .vs-line-chart-end circle:first-child { fill: var(--vs-line-chart-accent); transform-box: fill-box; transform-origin: center; opacity: 0; }\n  .vs-line-chart.is-drawn .vs-line-chart-end circle:first-child { animation: vs-line-chart-pulse 2.4s cubic-bezier(.2,.6,.3,1) .2s infinite; }\n  .vs-line-chart.is-off .vs-line-chart-end circle:first-child { animation-play-state: paused; }\n  .vs-line-chart-end circle:last-child { fill: var(--vs-line-chart-accent); stroke: var(--vs-line-chart-dot-ring); stroke-width: 2.5; }\n  @keyframes vs-line-chart-pulse { 0% { transform: scale(1); opacity: .55; } 70%, 100% { transform: scale(3.4); opacity: 0; } }\n\n  /* ── tooltip ── */\n  .vs-line-chart-tip {\n    position: absolute; left: 0; top: 0; z-index: 2; min-width: 196px; padding: 11px 13px 11px; border-radius: 13px; pointer-events: none;\n    font-size: 12.5px; line-height: 1; color: var(--vs-line-chart-ink); background: var(--vs-line-chart-tip);\n    -webkit-backdrop-filter: blur(16px) saturate(1.4); backdrop-filter: blur(16px) saturate(1.4);\n    box-shadow: 0 0 0 .5px var(--vs-line-chart-ring), inset 0 .5px 0 rgba(255,255,255,.08), 0 2px 4px rgba(0,0,0,.14), 0 14px 34px -10px rgba(0,0,0,.45);\n    will-change: transform;\n  }\n  .vs-line-chart[data-vs-line-chart-theme=\"light\"] .vs-line-chart-tip { box-shadow: 0 0 0 .5px rgba(16,24,40,.12), inset 0 .5px 0 #fff, 0 2px 4px rgba(16,24,40,.06), 0 14px 34px -12px rgba(16,24,40,.28); }\n  .vs-line-chart-tip-d { font-family: var(--vs-line-chart-mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--vs-line-chart-mute); margin-bottom: 10px; }\n  .vs-line-chart-tip-r { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 8px; height: 21px; }\n  .vs-line-chart-tip-r .vs-line-chart-key { width: 12px; }\n  .vs-line-chart-tip-r span:nth-child(2) { color: var(--vs-line-chart-mute); }\n  .vs-line-chart-tip-r b { font-weight: 500; text-align: right; }\n  .vs-line-chart-tip-x { display: flex; justify-content: space-between; margin-top: 8px; padding-top: 9px; border-top: .5px solid color-mix(in srgb, var(--vs-line-chart-ink) 14%, transparent); color: var(--vs-line-chart-mute); }\n  .vs-line-chart-tip-x b { font-weight: 500; color: var(--vs-line-chart-up); }\n  .vs-line-chart-tip-x b.is-down { color: var(--vs-line-chart-down); }\n  .vs-line-chart-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n  @media (prefers-reduced-motion: reduce) {\n    .vs-line-chart .vs-line-chart-end circle:first-child { animation: none; opacity: 0; }\n    .vs-line-chart-end, .vs-line-chart-delta, .vs-line-chart-vs { transition: none; }\n  }\n  @media (max-width: 560px) {\n    .vs-line-chart { padding: 20px 16px 12px; --vs-line-chart-height: 260px; }\n    .vs-line-chart-value { font-size: 36px; }\n    .vs-line-chart-legend { margin: 0 0 0 -10px; }\n    .vs-line-chart-tip { min-width: 0; padding: 9px 11px; font-size: 11.5px; }\n    .vs-line-chart-tip-r { gap: 7px; height: 19px; }\n  }\n</style>\n<figure class=\"vs-line-chart\" data-vs-line-chart-curve=\"smooth\" data-vs-line-chart-area=\"on\">\n  <figcaption class=\"vs-line-chart-head\">\n    <div>\n      <div class=\"vs-line-chart-title\">Weekly active readers</div>\n      <div class=\"vs-line-chart-kpi\"><span class=\"vs-line-chart-value\">84,210</span><span class=\"vs-line-chart-delta\" data-dir=\"up\">6.2%</span><span class=\"vs-line-chart-vs\">vs previous period</span></div>\n    </div>\n  </figcaption>\n  <script type=\"application/json\">{\n    \"label\": \"Weekly active readers, 30 March to 21 September 2026\",\n    \"start\": \"2026-03-30\", \"step\": 7, \"locale\": \"en-GB\",\n    \"series\": [\n      { \"name\": \"Readers\", \"style\": \"accent\", \"values\": [60300,61904,63772,63129,62298,63801,60928,61889,65778,68443,68163,68982,70150,69347,76585,75230,76909,75502,76861,77032,78599,79834,82921,83983,82614,84210] },\n      { \"name\": \"Returning\", \"style\": \"ink\", \"values\": [32938,34058,35295,35369,34872,35199,34354,34577,36588,39168,39380,39610,40829,40371,43756,43982,44655,43374,44813,45551,46655,47156,49132,49593,49944,50612] },\n      { \"name\": \"Previous period\", \"style\": \"dashed\", \"compare\": true, \"values\": [58304,59407,58718,58232,58797,59627,61633,62832,64973,65718,65960,65614,66108,67216,67376,70032,71508,72807,74390,73531,74739,75433,76149,76263,76965,79294] }\n    ]\n  }</script>\n</figure>\n<script>\n(() => {\n  if (window.__vsLineChart) return; window.__vsLineChart = true;\n  const NS = 'http://www.w3.org/2000/svg';\n  const RM = matchMedia('(prefers-reduced-motion: reduce)');\n  let uid = 0;\n  const el = (tag, attrs, parent, svg = true) => {\n    const n = svg ? document.createElementNS(NS, tag) : document.createElement(tag);\n    for (const k in attrs || {}) n.setAttribute(k, attrs[k]);\n    if (parent) parent.appendChild(n);\n    return n;\n  };\n  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  // cubic-bezier(.5,0,.14,1): a soft start that lands without a thud\n  const bez = (x1, y1, x2, y2) => (t) => {\n    const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx, cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;\n    let u = t; for (let i = 0; i < 6; i++) { const x = ((ax * u + bx) * u + cx) * u - t, d = (3 * ax * u + 2 * bx) * u + cx; if (Math.abs(d) < 1e-6) break; u -= x / d; }\n    u = clamp(u, 0, 1); return ((ay * u + by) * u + cy) * u;\n  };\n  const ease = bez(.5, 0, .14, 1);\n  // semi-implicit Euler spring; interruptible because it keeps its velocity when the target moves\n  const spring = (x, k, c) => ({ x, v: 0, t: x, k, c });\n  const step = (s, dt) => { const a = -s.k * (s.x - s.t) - s.c * s.v; s.v += a * dt; s.x += s.v * dt; };\n  const still = (s, eps = .02) => Math.abs(s.x - s.t) < eps && Math.abs(s.v) < eps * 10;\n  const snap = (s) => { s.x = s.t; s.v = 0; };\n  // Steffen / d3.curveMonotoneX tangents (uniform spacing): monotone, never overshoots a point\n  const tangents = (y) => {\n    const n = y.length, m = new Array(n).fill(0); if (n < 2) return m;\n    const s = []; for (let i = 0; i < n - 1; i++) s.push(y[i + 1] - y[i]);\n    for (let i = 1; i < n - 1; i++) {\n      const a = s[i - 1], b = s[i], p = (a + b) / 2;\n      m[i] = (Math.sign(a) + Math.sign(b)) * Math.min(Math.abs(a), Math.abs(b), .5 * Math.abs(p)) || 0;\n    }\n    m[0] = n > 2 ? (3 * s[0] - m[1]) / 2 : s[0];\n    m[n - 1] = n > 2 ? (3 * s[n - 2] - m[n - 2]) / 2 : s[0];\n    return m;\n  };\n\n  function mount(root) {\n    if (root.vsLineChart) return;\n    const src = root.querySelector('script[type=\"application/json\"]');\n    let cfg; try { cfg = JSON.parse(src.textContent); } catch (e) { return; }\n    const id = 'vslc' + (++uid);\n    const S = cfg.series.map((s, i) => ({ ...s, style: s.style || (i === 0 ? 'accent' : i === 1 ? 'ink' : 'dashed'), vis: spring(1, 210, 26), on: true }));\n    const main = S[0], cmp = S.find((s) => s.compare && s !== main);\n    const n = main.values.length;\n    const loc = cfg.locale || undefined;\n    const nf = new Intl.NumberFormat(loc, { minimumFractionDigits: cfg.decimals || 0, maximumFractionDigits: cfg.decimals || 0 });\n    const cf = new Intl.NumberFormat(loc, { notation: 'compact', maximumFractionDigits: 1 });\n    const fmt = (v) => (cfg.prefix || '') + nf.format(v) + (cfg.suffix || '');\n    const t0 = cfg.start ? new Date(cfg.start + 'T12:00:00') : null, stepD = cfg.step || 7;\n    const dateAt = (u) => t0 ? new Date(t0.getTime() + Math.round(u * stepD) * 864e5) : null;\n    const dFmt = new Intl.DateTimeFormat(loc, { weekday: 'short', day: 'numeric', month: 'short' });\n    const dLong = new Intl.DateTimeFormat(loc, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });\n    const mFmt = new Intl.DateTimeFormat(loc, { month: 'short' });\n    const labelAt = (u) => t0 ? dFmt.format(dateAt(u)) : (cfg.labels || [])[Math.round(u)] || '';\n\n    // ── DOM ──\n    const head = root.querySelector('.vs-line-chart-head') || el('div', { class: 'vs-line-chart-head' }, root, false);\n    const legend = el('div', { class: 'vs-line-chart-legend', role: 'group', 'aria-label': 'Series' }, head, false);\n    const keyVar = (s) => s.style === 'accent' ? 'var(--vs-line-chart-accent)' : s.style === 'ink' ? 'color-mix(in srgb, var(--vs-line-chart-ink) 70%, transparent)' : 'var(--vs-line-chart-faint)';\n    const key = (s) => `<i class=\"vs-line-chart-key\" data-s=\"${s.style}\" style=\"--k:${keyVar(s)}\"></i>`;\n    S.forEach((s) => {\n      const b = el('button', { type: 'button', 'aria-pressed': 'true' }, legend, false);\n      b.innerHTML = key(s) + s.name; s.btn = b;\n      b.addEventListener('click', () => {\n        if (s.on && S.filter((x) => x.on).length === 1) return;\n        s.on = !s.on; s.vis.t = s.on ? 1 : 0; b.setAttribute('aria-pressed', s.on);\n        if (RM.matches) snap(s.vis);\n        paintTip(true); kick();\n      });\n    });\n    const valueEl = root.querySelector('.vs-line-chart-value');\n    const valueText = valueEl ? valueEl.textContent : '';\n    if (valueEl && !RM.matches) valueEl.textContent = fmt(main.values[0]);\n    const plot = el('div', { class: 'vs-line-chart-plot', tabindex: '0', role: 'group', 'aria-roledescription': 'chart',\n      'aria-label': (cfg.label || main.name) + '. Use the left and right arrow keys to read each point.' }, null, false);\n    root.insertBefore(plot, src);\n    const svg = el('svg', { 'aria-hidden': 'true', focusable: 'false' }, plot);\n    const defs = el('defs', {}, svg);\n    const grad = el('linearGradient', { id: id + 'g', x1: 0, y1: 0, x2: 0, y2: 1 }, defs);\n    el('stop', { offset: '0', class: 'vs-line-chart-stop-a' }, grad); el('stop', { offset: '.55', class: 'vs-line-chart-stop-b' }, grad); el('stop', { offset: '1', class: 'vs-line-chart-stop-c' }, grad);\n    // soft left-to-right reveal: a gradient that feathers out just behind the drawing head\n    const rg = el('linearGradient', { id: id + 'r', gradientUnits: 'userSpaceOnUse', x1: 0, x2: 1, y1: 0, y2: 0 }, defs);\n    el('stop', { offset: '0', 'stop-color': '#fff' }, rg); el('stop', { offset: '1', 'stop-color': '#fff', 'stop-opacity': '0' }, rg);\n    const clip = el('mask', { id: id + 'c', maskUnits: 'userSpaceOnUse', x: -40, y: -40, width: 99999, height: 99999 }, defs);\n    const clipR = el('rect', { x: -40, y: -40, height: 99999, width: 99999, fill: `url(#${id}r)` }, clip);\n    const mask = el('mask', { id: id + 'm', maskUnits: 'userSpaceOnUse', x: -40, y: -40, width: 99999, height: 99999 }, defs);\n    const mPast = el('rect', { x: -40, y: -40, height: 99999, fill: '#fff' }, mask), mFut = el('rect', { y: -40, height: 99999, fill: '#fff' }, mask);\n    const gGrid = el('g', {}, svg), gY = el('g', {}, svg), gX = el('g', {}, svg);\n    const gData = el('g', { mask: `url(#${id}m)` }, svg);\n    const gArea = el('g', { class: 'vs-line-chart-areas', mask: `url(#${id}c)` }, gData);\n    const area = el('path', { fill: `url(#${id}g)` }, gArea);\n    const gSec = el('g', { mask: `url(#${id}c)` }, gData);\n    S.forEach((s) => { s.path = el('path', { class: 'vs-line-chart-line', 'data-s': s.style }, s === main ? null : gSec); });\n    gData.appendChild(main.path);\n    const headDot = el('circle', { class: 'vs-line-chart-head-dot', r: 3.5 }, svg);\n    const end = el('g', { class: 'vs-line-chart-end' }, svg); el('circle', { r: 5.5 }, end); el('circle', { r: 5.5 }, end);\n    const gHov = el('g', { class: 'vs-line-chart-hover' }, svg);\n    const cross = el('line', { class: 'vs-line-chart-cross' }, gHov);\n    const halo = el('circle', { class: 'vs-line-chart-halo', r: 13 }, gHov);\n    S.slice().reverse().forEach((s) => { s.dot = el('circle', { class: 'vs-line-chart-dot', 'data-s': s.style, r: s === main ? 5.5 : 3.75 }, gHov); });\n    const tip = el('div', { class: 'vs-line-chart-tip vs-line-chart-hover', 'aria-hidden': 'true' }, plot, false);\n    // visually hidden data table + polite live region\n    const srw = el('div', { class: 'vs-line-chart-sr' }, null, false), tbl = el('table', {}, srw, false);\n    tbl.innerHTML = `<caption>${cfg.label || main.name}</caption><thead><tr><th scope=\"col\">${t0 ? 'Date' : 'Point'}</th>${S.map((s) => `<th scope=\"col\">${s.name}</th>`).join('')}</tr></thead><tbody>` +\n      main.values.map((_, i) => `<tr><th scope=\"row\">${t0 ? dLong.format(dateAt(i)) : labelAt(i)}</th>${S.map((s) => `<td>${fmt(s.values[i])}</td>`).join('')}</tr>`).join('') + '</tbody>';\n    root.insertBefore(srw, src);\n    const live = el('div', { class: 'vs-line-chart-sr', 'aria-live': 'polite' }, root, false);\n\n    // ── geometry ──\n    let W = 0, H = 0, L = 44, R = 8, T = 10, B = 28, yMax = 1, lx = [], ly = [], len = 0;\n    const X = (u) => L + (n > 1 ? u / (n - 1) : .5) * (W - L - R);\n    const Y = (v) => T + (1 - v / yMax) * (H - T - B);\n    const curve = () => root.getAttribute('data-vs-line-chart-curve') || 'smooth';\n    const valAt = (s, u) => {\n      u = clamp(u, 0, n - 1); const i = Math.min(n - 2, Math.floor(u)), t = u - i, v = s.values;\n      if (n < 2) return v[0];\n      const c = curve();\n      if (c === 'step') return t >= .999999 ? v[i + 1] : v[i];\n      if (c === 'linear') return v[i] + (v[i + 1] - v[i]) * t;\n      const m = s.m, t2 = t * t, t3 = t2 * t;\n      return (2 * t3 - 3 * t2 + 1) * v[i] + (t3 - 2 * t2 + t) * m[i] + (-2 * t3 + 3 * t2) * v[i + 1] + (t3 - t2) * m[i + 1];\n    };\n    const pathOf = (s) => {\n      const v = s.values, c = curve(); let d = `M${X(0).toFixed(2)},${Y(v[0]).toFixed(2)}`;\n      for (let i = 0; i < n - 1; i++) {\n        const x0 = X(i), x1 = X(i + 1);\n        if (c === 'linear') d += `L${x1.toFixed(2)},${Y(v[i + 1]).toFixed(2)}`;\n        else if (c === 'step') d += `H${x1.toFixed(2)}V${Y(v[i + 1]).toFixed(2)}`;\n        else d += `C${(x0 + (x1 - x0) / 3).toFixed(2)},${Y(v[i] + s.m[i] / 3).toFixed(2)} ${(x1 - (x1 - x0) / 3).toFixed(2)},${Y(v[i + 1] - s.m[i + 1] / 3).toFixed(2)} ${x1.toFixed(2)},${Y(v[i + 1]).toFixed(2)}`;\n      }\n      return d;\n    };\n    const niceStep = (max, count) => { const raw = max / count, p = Math.pow(10, Math.floor(Math.log10(raw))), f = raw / p; return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * p; };\n    function layout() {\n      W = plot.clientWidth; H = plot.clientHeight; if (!W || !H) return;\n      const narrow = W < 480; L = narrow ? 34 : 46; B = 28;\n      svg.setAttribute('viewBox', `0 0 ${W} ${H}`);\n      S.forEach((s) => { s.m = tangents(s.values); });\n      const max = Math.max(...S.flatMap((s) => s.values)) * 1.04;\n      const st = niceStep(max, narrow ? 4 : 5); yMax = Math.ceil(max / st) * st;\n      gGrid.textContent = ''; gY.textContent = ''; gX.textContent = '';\n      for (let v = 0; v <= yMax + 1e-9; v += st) {\n        const y = Math.round(Y(v)) + .5;\n        el('line', { class: 'vs-line-chart-gl' + (v === 0 ? ' is-base' : ''), x1: L, x2: W - R + 4, y1: y, y2: y }, gGrid);\n        const t = el('text', { x: L - 12, y: y + 3.5, 'text-anchor': 'end' }, gY); t.textContent = (cfg.prefix || '') + cf.format(v);\n      }\n      // x labels: the first point of each month (or every k-th label), thinned so they never collide\n      const ticks = [];\n      for (let i = 0; i < n; i++) {\n        if (t0) { const d = dateAt(i), p = i ? dateAt(i - 1) : null; if (p ? d.getMonth() !== p.getMonth() : d.getDate() <= Math.max(3, stepD)) ticks.push([i, mFmt.format(d)]); }\n        else if (cfg.labels) ticks.push([i, cfg.labels[i]]);\n      }\n      let last = -1e9; const gap = narrow ? 40 : 52;\n      ticks.forEach(([i, txt]) => {\n        const x = X(i); if (x - last < gap || x > W - R - 10) return; last = x;\n        const t = el('text', { x: x, y: H - 8, 'text-anchor': i === 0 ? 'start' : 'middle' }, gX); t.textContent = txt;\n        el('line', { class: 'vs-line-chart-gl', x1: Math.round(x) + .5, x2: Math.round(x) + .5, y1: H - B, y2: H - B + 4 }, gGrid);\n      });\n      S.forEach((s) => s.path.setAttribute('d', pathOf(s)));\n      area.setAttribute('d', pathOf(main) + `L${X(n - 1).toFixed(2)},${Y(0)}L${X(0).toFixed(2)},${Y(0)}Z`);\n      len = main.path.getTotalLength(); main.path.style.strokeDasharray = `${len + 2} ${len + 2}`;\n      lx = []; ly = []; const N = 180;\n      for (let k = 0; k <= N; k++) { const p = main.path.getPointAtLength(k / N * len); lx.push(p.x); ly.push(p.y); }\n      const ex = X(n - 1), ey = Y(main.values[n - 1]);\n      end.querySelectorAll('circle').forEach((c) => { c.setAttribute('cx', ex); c.setAttribute('cy', ey); });\n      cross.setAttribute('y1', T - 6); cross.setAttribute('y2', H - B);\n      if (!cx.x && !hover) { cx.x = cx.t = X(n - 1); }\n      paint(); paintTip(true);\n    }\n\n    // ── state ──\n    let undo = 0, undoFrom = 1, draw = 0, drawStart = 0, drawing = false, drawn = false, visible = false, hover = false, u = n - 1, raf = 0, last = 0, lastLabel = '', touchTimer = 0;\n    const cx = spring(0, 520, 40), tx = spring(0, 300, 30), ty = spring(0, 300, 30), dim = spring(0, 240, 30);\n    const lookup = (p) => { const f = p * (lx.length - 1), i = Math.min(lx.length - 2, Math.floor(f)), t = f - i; return [lx[i] + (lx[i + 1] - lx[i]) * t, ly[i] + (ly[i + 1] - ly[i]) * t]; };\n    function paint() {\n      if (!W) return;\n      const e = drawing || !drawn ? ease(clamp(draw, 0, 1)) : 1;\n      main.path.style.strokeDashoffset = (len + 2) * (1 - e);\n      const [hx, hy] = lx.length ? lookup(e) : [0, 0];\n      if (e >= 1) { rg.setAttribute('x1', 99990); rg.setAttribute('x2', 99999); }\n      else { rg.setAttribute('x1', hx - 70); rg.setAttribute('x2', hx + 2); }\n      area.style.opacity = Math.min(1, e * 1.25) * main.vis.x;\n      headDot.setAttribute('cx', hx); headDot.setAttribute('cy', hy);\n      headDot.style.opacity = drawing && e < .995 ? Math.min(1, e * 8) : 0;\n      if (valueEl && drawing) valueEl.textContent = fmt(Math.round(valAt(main, clamp((hx - L) / (W - L - R), 0, 1) * (n - 1))));\n      S.forEach((s) => { s.path.style.opacity = s.vis.x; s.dot.style.opacity = s.vis.x; });\n      end.style.opacity = main.vis.x;\n      // crosshair + dots ride the real curve at the sprung x\n      const x = cx.x, uu = clamp((x - L) / (W - L - R), 0, 1) * (n - 1), px = Math.round(x) + .5;\n      cross.setAttribute('x1', px); cross.setAttribute('x2', px);\n      S.forEach((s) => { s.dot.setAttribute('cx', x); s.dot.setAttribute('cy', Y(valAt(s, uu))); });\n      halo.setAttribute('cx', x); halo.setAttribute('cy', Y(valAt(main, uu))); halo.style.opacity = .18 * main.vis.x;\n      mPast.setAttribute('width', dim.x < .002 ? 99999 : Math.max(0, x + 41)); mFut.setAttribute('x', x); mFut.setAttribute('width', 99999);\n      mFut.setAttribute('fill-opacity', 1 - .6 * dim.x);\n      tip.style.transform = `translate3d(${tx.x.toFixed(1)}px,${ty.x.toFixed(1)}px,0)`;\n      return uu;\n    }\n    function paintTip(force) {\n      if (!W) return;\n      const uu = clamp((cx.t - L) / (W - L - R), 0, 1) * (n - 1);\n      const label = labelAt(uu) + S.map((s) => s.on ? Math.round(valAt(s, uu)) : '').join('|');\n      if (label !== lastLabel || force) {\n        lastLabel = label;\n        let h = `<div class=\"vs-line-chart-tip-d\">${labelAt(uu)}</div>`;\n        S.forEach((s) => { if (s.on) h += `<div class=\"vs-line-chart-tip-r\">${key(s)}<span>${s.name}</span><b>${fmt(Math.round(valAt(s, uu)))}</b></div>`; });\n        if (cmp && main.on && cmp.on) {\n          const a = valAt(main, uu), b = valAt(cmp, uu), d = (a - b) / b * 100;\n          h += `<div class=\"vs-line-chart-tip-x\"><span>vs ${cmp.name.toLowerCase()}</span><b class=\"${d < 0 ? 'is-down' : ''}\">${d >= 0 ? '+' : '−'}${Math.abs(d).toFixed(1)}%</b></div>`;\n        }\n        tip.innerHTML = h;\n      }\n      const tw = tip.offsetWidth, th = tip.offsetHeight, gapX = 20;\n      const x = cx.t, right = x + gapX + tw <= W;\n      tx.t = right ? x + gapX : x - gapX - tw;\n      const my = Y(valAt(main, uu));\n      ty.t = clamp(my - th * .5, 0, H - B - th);\n    }\n    function frame(now) {\n      raf = 0;\n      const dt = Math.min(.034, (now - (last || now)) / 1000); last = now;\n      if (drawing) {\n        if (undo) { draw = undoFrom * (1 - (now - drawStart) / undo); if (draw <= 0) { draw = 0; drawing = false; undo = 0; } }\n        else draw = (now - drawStart) / (parseFloat(getComputedStyle(root).getPropertyValue('--vs-line-chart-draw')) || 1800);\n        if (!undo && draw >= 1) { draw = 1; drawing = false; drawn = true; root.classList.add('is-drawn'); if (valueEl) valueEl.textContent = valueText; }\n      }\n      const sp = [cx, tx, ty, dim, ...S.map((s) => s.vis)];\n      for (let k = 0; k < 2; k++) sp.forEach((s) => step(s, dt / 2));\n      sp.forEach((s) => { if (still(s)) snap(s); });\n      paint();\n      if (drawing || sp.some((s) => !still(s))) kick(); else last = 0;\n    }\n    function kick() { if (!raf && visible) raf = requestAnimationFrame(frame); }\n    function startDraw() {\n      root.classList.remove('is-drawn');\n      if (RM.matches) { draw = 1; drawing = false; drawn = true; root.classList.add('is-drawn'); paint(); return; }\n      undo = 0; draw = 0; drawn = false; drawing = true; drawStart = performance.now(); last = 0; kick();\n    }\n    // rewinds the line back into its start point (used for looping demos), then waits for replay()\n    function undraw(ms = 700) {\n      if (RM.matches) return; root.classList.remove('is-drawn');\n      undoFrom = drawn ? 1 : Math.min(1, draw); drawn = false; drawing = true; undo = ms; drawStart = performance.now(); last = 0; kick();\n    }\n\n    // ── interaction ──\n    const setU = (v, instant) => {\n      u = clamp(v, 0, n - 1); cx.t = X(u); dim.t = 1;\n      if (!hover) { hover = true; root.classList.add('is-hover'); if (!cx.x || instant) { snap(cx); } }\n      paintTip();\n      if (!hover || instant || RM.matches) { snap(tx); snap(ty); }\n      if (RM.matches) { snap(cx); snap(dim); }\n      const uu = u, d = dateAt(uu);\n      root.dispatchEvent(new CustomEvent('vs-line-chart-point', { bubbles: true, detail: { index: uu, date: d, values: S.map((s) => valAt(s, uu)) } }));\n      kick();\n    };\n    let fresh = true;\n    const enter = (clientX) => {\n      const r = plot.getBoundingClientRect(), uu = clamp((clientX - r.left - L) / (W - L - R), 0, 1) * (n - 1);\n      const wasHover = hover; setU(uu);\n      if (!wasHover && fresh) { snap(cx); paint(); paintTip(); snap(tx); snap(ty); }\n      fresh = false;\n    };\n    const leave = () => { hover = false; fresh = true; root.classList.remove('is-hover'); dim.t = 0; kick(); };\n    plot.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse' || e.buttons) enter(e.clientX); });\n    plot.addEventListener('pointerdown', (e) => { clearTimeout(touchTimer); enter(e.clientX); });\n    plot.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') leave(); });\n    plot.addEventListener('pointerup', (e) => { if (e.pointerType !== 'mouse') { clearTimeout(touchTimer); touchTimer = setTimeout(leave, 1600); } });\n    plot.addEventListener('pointercancel', () => { clearTimeout(touchTimer); touchTimer = setTimeout(leave, 900); });\n    const announce = () => { live.textContent = `${t0 ? dLong.format(dateAt(Math.round(u))) : labelAt(u)}: ` + S.filter((s) => s.on).map((s) => `${s.name} ${fmt(s.values[Math.round(u)])}`).join(', '); };\n    plot.addEventListener('keydown', (e) => {\n      const k = e.key; let i = Math.round(hover ? u : n - 1);\n      if (k === 'ArrowRight' || k === 'ArrowLeft') i += (k === 'ArrowRight' ? 1 : -1) * (e.shiftKey ? 4 : 1) * (hover ? 1 : 0);\n      else if (k === 'Home') i = 0; else if (k === 'End') i = n - 1;\n      else if (k === 'Escape') { leave(); return; } else return;\n      e.preventDefault(); setU(clamp(i, 0, n - 1)); announce();\n    });\n    plot.addEventListener('focus', () => { if (plot.matches(':focus-visible') && !hover) { setU(n - 1, true); announce(); } });\n    plot.addEventListener('blur', leave);\n\n    new ResizeObserver(() => { const had = hover; layout(); if (had) { cx.t = X(u); snap(cx); paintTip(); snap(tx); snap(ty); } paint(); }).observe(plot);\n    new MutationObserver(() => { layout(); }).observe(root, { attributes: true, attributeFilter: ['data-vs-line-chart-curve'] });\n    new IntersectionObserver(([e]) => {\n      visible = e.isIntersecting; root.classList.toggle('is-off', !visible);\n      if (visible && !drawn && !drawing && e.intersectionRatio > .25) startDraw();\n      if (visible) kick();\n    }, { threshold: [0, .3] }).observe(root);\n    layout();\n    root.vsLineChart = { replay: startDraw, undraw, focusPoint: (i) => setU(i), leave, get index() { return u; } };\n  }\n  const init = () => document.querySelectorAll('.vs-line-chart').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Data display",
    "tags": [
      "line chart",
      "area chart",
      "chart",
      "dashboard",
      "analytics",
      "tooltip",
      "crosshair",
      "svg",
      "spring",
      "keyboard"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&family=Space+Mono&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#4f8cff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/line-chart/index.html",
    "thumbnail": "https://vividsites.app/api/component/line-chart/thumb.jpg",
    "video": "https://vividsites.app/api/component/line-chart/thumb.mp4",
    "page": "https://vividsites.app/c/line-chart"
  }
}