# Line charts

> Four hand-drawn SVG line charts — multi-series, single metric, period comparison and annotated steps — that reveal left to right, morph with springs and read out values on hover or arrow keys.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/line-charts
- Install: `npx shadcn@latest add @geturui/line-charts`
- Dependencies: motion, lucide-react

## Variants

### Multi-series

Up to four series, volt first and the rest neutral or dashed; legend toggles rescale the axis with a spring morph and the crosshair reads every visible line.

Install: `npx shadcn@latest add @geturui/line-charts-multi-series`

```tsx
import { MultiSeriesLineChart } from "@/components/examples/line-charts/multi-series";

export function Platforms() {
  return (
    <MultiSeriesLineChart
      title="Weekly active users"
      labels={["Jan", "Feb", "Mar", "Apr"]}
      series={[
        { id: "web", label: "Web", values: [18.2, 19.4, 21.1, 22.8] },
        { id: "ios", label: "iOS", values: [9.1, 9.8, 10.4, 11.9] },
      ]}
    />
  );
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| title / subtitle | `string` | — | Heading and caption. |
| labels | `string[]` | — | X-axis labels, one per point. |
| series | `LineSeries[]` | — | { id, label, values }. The first series is primary. |
| formatValue | `(n: number) => string` | — | Formats tooltip values. |
| formatTick | `(n: number) => string` | — | Formats y-axis ticks. |

### Gradient metric

One metric under a volt gradient with labelled peak and low markers, an average guide, a glowing latest point and a headline that follows the crosshair.

Install: `npx shadcn@latest add @geturui/line-charts-gradient-metric`

```tsx
import { GradientMetricChart } from "@/components/examples/line-charts/gradient-metric";

export function Signups() {
  return <GradientMetricChart title="Daily signups" values={values} labels={days} caption="Last 30 days" />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| title | `string` | "Daily signups" | Metric name. |
| values | `number[]` | — | Series, oldest first. |
| labels | `string[]` | — | One label per value. |
| formatValue | `(n: number) => string` | — | Formats headline, ticks and markers. |
| caption | `string` | "Last 30 days" | Caption under the headline. |

### Period comparison

This period in solid volt against the last in dashed neutral, the gap shaded, with week, month and quarter tabs that morph both lines together.

Install: `npx shadcn@latest add @geturui/line-charts-period-comparison`

```tsx
import { PeriodComparisonChart } from "@/components/examples/line-charts/period-comparison";

export function Bookings() {
  return (
    <PeriodComparisonChart
      title="Cumulative bookings"
      periods={[{ key: "Month", previousLabel: "last month", labels, current, previous }]}
      defaultPeriod="Month"
    />
  );
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| title | `string` | "Cumulative bookings" | Card heading. |
| periods | `ComparisonPeriod[]` | — | { key, labels, current, previous, previousLabel }. Equal point counts let lines morph. |
| defaultPeriod | `string` | "Month" | Initially selected tab. |
| formatValue | `(n: number) => string` | — | Formats values and ticks. |

### Step with annotations

A step line for values that change in jumps, a dashed plan limit and numbered notes that pop a callout on hover or focus and step with arrow keys.

Install: `npx shadcn@latest add @geturui/line-charts-step-annotations`

```tsx
import { StepAnnotationsChart } from "@/components/examples/line-charts/step-annotations";

export function Seats() {
  return (
    <StepAnnotationsChart
      values={[12, 12, 18, 24]}
      labels={["Jun", "Jul", "Aug", "Sep"]}
      limit={25}
      annotations={[{ index: 2, title: "Design joined", body: "Six seats added." }]}
    />
  );
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| title / subtitle | `string` | — | Heading and caption. |
| labels | `string[]` | — | One label per step. |
| values | `number[]` | — | Value held from each label until the next. |
| annotations | `StepAnnotation[]` | — | { index, title, body } notes pinned to points. |
| limit | `number` | 40 | Dashed ceiling line. |
| unit | `string` | "seats" | Unit used in copy. |

## Accessibility

- Each chart is role="img" with a summary of its range, extremes or comparison; the plot area is focusable and arrow keys move the crosshair with a polite readout.
- Legend chips and period tabs are buttons with aria-pressed; annotation markers are buttons that step through with left and right arrows.
- Axis labels are HTML, strokes stay crisp at any size, and reduced motion swaps the reveal and morphs for an instant render.

## Source

### components/examples/line-charts/multi-series.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface LineSeries {
  id: string;
  label: string;
  /** One value per label, oldest first. */
  values: number[];
}

export interface MultiSeriesLineChartProps {
  title?: string;
  subtitle?: string;
  /** X-axis labels, one per point. */
  labels?: string[];
  /** First series is the primary (volt); the rest render in neutral tones. */
  series?: LineSeries[];
  formatValue?: (n: number) => string;
  /** Formats the y-axis ticks. */
  formatTick?: (n: number) => string;
  className?: string;
}

const LABELS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"];
const SERIES: LineSeries[] = [
  { id: "web", label: "Web app", values: [18.2, 19.4, 21.1, 20.6, 22.8, 24.9, 26.3, 25.8, 28.4, 30.1, 31.7, 34.2] },
  { id: "ios", label: "iOS", values: [9.1, 9.8, 10.4, 11.9, 12.2, 13.6, 14.1, 15.8, 16.3, 17.9, 18.4, 19.6] },
  { id: "android", label: "Android", values: [6.4, 6.9, 7.2, 7.1, 8.0, 8.6, 9.3, 9.1, 10.2, 10.8, 11.6, 12.1] },
];
const STYLES = [
  { stroke: "var(--volt-ink)", width: 2.25, dash: undefined },
  { stroke: "var(--ink-muted)", width: 1.75, dash: undefined },
  { stroke: "var(--ink-subtle)", width: 1.75, dash: "5 4" },
  { stroke: "var(--ink-subtle)", width: 1.5, dash: "1.5 3.5" },
];

function niceTicks(max: number, count = 4) {
  const raw = Math.max(max, 1e-6) / count;
  const mag = 10 ** Math.floor(Math.log10(raw));
  const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
  return Array.from({ length: count + 1 }, (_, i) => i * step);
}

function smooth(pts: [number, number][]) {
  const f = (n: number) => n.toFixed(2);
  return pts
    .map((p, i) => {
      if (i === 0) return `M${f(p[0])},${f(p[1])}`;
      const a = pts[i - 2] ?? pts[i - 1];
      const b = pts[i - 1];
      const c = pts[i + 1] ?? p;
      return `C${f(b[0] + (p[0] - a[0]) / 6)},${f(b[1] + (p[1] - a[1]) / 6)} ${f(p[0] - (c[0] - b[0]) / 6)},${f(p[1] - (c[1] - b[1]) / 6)} ${f(p[0])},${f(p[1])}`;
    })
    .join("");
}

/** Multi-series line chart: legend toggles rescale the axis with a spring morph, and a crosshair reads every visible series on hover or arrow keys. */
export function MultiSeriesLineChart({
  title = "Weekly active users",
  subtitle = "By platform, in thousands",
  labels = LABELS,
  series = SERIES,
  formatValue = (n) => `${n.toFixed(1)}k`,
  formatTick = (n) => (n ? `${n}k` : "0"),
  className,
}: MultiSeriesLineChartProps) {
  const reduce = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "-40px" });
  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [hidden, setHidden] = React.useState<Set<string>>(new Set());
  const [active, setActive] = React.useState<number | null>(null);

  const n = labels.length;
  const visible = series.filter((s) => !hidden.has(s.id));
  const ticks = niceTicks(Math.max(...visible.flatMap((s) => s.values), 0));
  const top = ticks[ticks.length - 1] || 1;
  const xp = (i: number) => (n > 1 ? (i / (n - 1)) * 100 : 50);
  const yp = (v: number) => (1 - v / top) * 100;
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 200, damping: 30 };

  const toggle = (id: string) =>
    setHidden((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else if (series.length - next.size > 1) next.add(id);
      return next;
    });

  function onMove(e: React.PointerEvent<HTMLDivElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    setActive(Math.max(0, Math.min(n - 1, Math.round(((e.clientX - r.left) / r.width) * (n - 1)))));
  }
  function onKey(e: React.KeyboardEvent) {
    const map: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1 };
    if (e.key in map) {
      e.preventDefault();
      setActive((a) => Math.max(0, Math.min(n - 1, (a ?? n - 1) + map[e.key])));
    } else if (e.key === "Home" || e.key === "End") {
      e.preventDefault();
      setActive(e.key === "Home" ? 0 : n - 1);
    } else if (e.key === "Escape") setActive(null);
  }

  const summary = `${title}. ${series.map((s) => `${s.label} from ${formatValue(s.values[0] ?? 0)} to ${formatValue(s.values[n - 1] ?? 0)}`).join("; ")}.`;

  return (
    <figure className={cn("@container w-full rounded-xl border border-border bg-surface-raised p-4 shadow-sm sm:p-5", className)}>
      <div className="flex flex-wrap items-start justify-between gap-3">
        <figcaption>
          <p className="text-[15px] font-semibold text-ink">{title}</p>
          <p className="text-[12.5px] text-ink-subtle">{subtitle}</p>
        </figcaption>
        <ul className="flex flex-wrap gap-1.5" aria-label="Toggle series">
          {series.map((s, i) => {
            const on = !hidden.has(s.id);
            const st = STYLES[i % STYLES.length];
            return (
              <li key={s.id}>
                <motion.button
                  type="button"
                  whileTap={{ scale: 0.96 }}
                  aria-pressed={on}
                  onClick={() => toggle(s.id)}
                  className={cn(
                    "inline-flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-[12px] font-medium outline-none transition-colors focus-visible:shadow-[var(--focus-ring)]",
                    on ? "border-border-strong/40 bg-surface text-ink" : "border-dashed border-border text-ink-subtle",
                  )}
                >
                  <svg aria-hidden width="14" height="4" className="overflow-visible">
                    <line x1="0" x2="14" y1="2" y2="2" style={{ stroke: st.stroke, opacity: on ? 1 : 0.35 }} strokeWidth={2.5} strokeDasharray={st.dash} strokeLinecap="round" />
                  </svg>
                  {s.label}
                </motion.button>
              </li>
            );
          })}
        </ul>
      </div>

      <div className="mt-5 grid grid-cols-[2.25rem_minmax(0,1fr)] gap-x-2">
        <div aria-hidden className="relative h-52 @md:h-60">
          {ticks.map((t) => (
            <motion.span key={t} layout="position" className="absolute right-0 -translate-y-1/2 font-mono text-[10.5px] tabular-nums text-ink-subtle" style={{ top: `${yp(t)}%` }}>
              {formatTick(t)}
            </motion.span>
          ))}
        </div>
        <div
          ref={ref}
          tabIndex={0}
          role="group"
          aria-label={`${title} chart. Use left and right arrow keys to read each month.`}
          onPointerMove={onMove}
          onPointerLeave={() => setActive(null)}
          onFocus={() => setActive((a) => a ?? n - 1)}
          onBlur={() => setActive(null)}
          onKeyDown={onKey}
          className="relative h-52 touch-none rounded-xs outline-none focus-visible:shadow-[var(--focus-ring)] @md:h-60"
        >
          <svg role="img" aria-label={summary} viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible">
            <defs>
              <clipPath id={`${uid}-reveal`}>
                <motion.rect x={-2} y={-10} height={120} initial={{ width: reduce ? 104 : 0 }} animate={{ width: inView ? 104 : reduce ? 104 : 0 }} transition={{ duration: reduce ? 0 : 1.2, ease: [0.65, 0, 0.35, 1] }} />
              </clipPath>
            </defs>
            {ticks.map((t, i) => (
              <motion.line key={t} initial={false} animate={{ y1: yp(t), y2: yp(t) }} transition={spring} x1={0} x2={100} className="stroke-border" strokeDasharray={i === 0 ? undefined : "2 4"} vectorEffect="non-scaling-stroke" />
            ))}
            <g clipPath={`url(#${uid}-reveal)`}>
              {series.map((s, i) => {
                const st = STYLES[i % STYLES.length];
                const d = smooth(s.values.map((v, k) => [xp(k), yp(v)]));
                return (
                  <motion.path
                    key={s.id}
                    initial={false}
                    animate={{ d, opacity: hidden.has(s.id) ? 0 : 1 }}
                    transition={{ d: spring, opacity: { duration: 0.2 } }}
                    fill="none"
                    style={{ stroke: st.stroke }}
                    strokeWidth={st.width}
                    strokeDasharray={st.dash}
                    strokeLinecap="round"
                    vectorEffect="non-scaling-stroke"
                  />
                );
              })}
            </g>
            {active !== null && <line x1={xp(active)} x2={xp(active)} y1={0} y2={100} className="stroke-ink-subtle" strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />}
          </svg>

          {active !== null &&
            series.map((s, i) =>
              hidden.has(s.id) ? null : (
                <motion.span
                  key={s.id}
                  aria-hidden
                  initial={false}
                  animate={{ left: `${xp(active)}%`, top: `${yp(s.values[active] ?? 0)}%` }}
                  transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }}
                  className="pointer-events-none absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface-raised"
                  style={{ background: STYLES[i % STYLES.length].stroke }}
                />
              ),
            )}

          <AnimatePresence>
            {active !== null && (
              <motion.div
                key="tip"
                aria-hidden
                initial={{ opacity: 0, scale: 0.96, left: `${xp(active)}%` }}
                animate={{ opacity: 1, scale: 1, left: `${xp(active)}%` }}
                exit={{ opacity: 0, transition: { duration: 0.1 } }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 480, damping: 36 }}
                style={{ x: xp(active) > 55 ? "calc(-100% - 12px)" : "12px" }}
                className="pointer-events-none absolute top-1 z-10 min-w-36 rounded-sm border border-border bg-surface-raised px-2.5 py-2 text-xs shadow-md"
              >
                <p className="mb-1 font-medium text-ink-muted">{labels[active]}</p>
                {series.map((s, i) =>
                  hidden.has(s.id) ? null : (
                    <p key={s.id} className="flex items-center justify-between gap-4">
                      <span className="flex items-center gap-1.5 text-ink-muted">
                        <span className="size-1.5 rounded-full" style={{ background: STYLES[i % STYLES.length].stroke }} />
                        {s.label}
                      </span>
                      <span className="font-mono font-semibold tabular-nums text-ink">{formatValue(s.values[active] ?? 0)}</span>
                    </p>
                  ),
                )}
              </motion.div>
            )}
          </AnimatePresence>
        </div>
        <div />
        <div aria-hidden className="relative mt-2 h-4">
          {labels.map((l, i) => (
            <span
              key={l + i}
              className={cn("absolute font-mono text-[10.5px] text-ink-subtle", i === 0 ? "" : i === n - 1 ? "-translate-x-full" : "-translate-x-1/2", (i % 2 === 1 || i === n - 2) && i !== n - 1 && "hidden @md:block")}
              style={{ left: `${xp(i)}%` }}
            >
              {l}
            </span>
          ))}
        </div>
      </div>
      <p className="sr-only" aria-live="polite">
        {active !== null ? `${labels[active]}: ${visible.map((s) => `${s.label} ${formatValue(s.values[active] ?? 0)}`).join(", ")}` : ""}
      </p>
    </figure>
  );
}

```

### components/examples/line-charts/gradient-metric.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface GradientMetricChartProps {
  title?: string;
  /** One value per label, oldest first. */
  values?: number[];
  labels?: string[];
  formatValue?: (n: number) => string;
  /** Caption under the headline value. */
  caption?: string;
  className?: string;
}

const VALUES = [212, 198, 226, 241, 233, 219, 186, 204, 247, 262, 258, 241, 275, 289, 301, 284, 268, 296, 312, 334, 327, 309, 341, 358, 412, 376, 352, 369, 388, 394];
const LABELS = VALUES.map((_, i) => {
  const d = new Date(Date.UTC(2026, 8, 3 + i));
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
});

function domain(min: number, max: number, count = 4) {
  const raw = Math.max(max - min, 1) / count;
  const mag = 10 ** Math.floor(Math.log10(raw));
  const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
  const lo = Math.floor(min / step) * step;
  const hi = Math.ceil(max / step) * step;
  return { lo, hi, ticks: Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => lo + i * step) };
}

function smooth(pts: [number, number][]) {
  const f = (n: number) => n.toFixed(2);
  return pts
    .map((p, i) => {
      if (i === 0) return `M${f(p[0])},${f(p[1])}`;
      const a = pts[i - 2] ?? pts[i - 1];
      const b = pts[i - 1];
      const c = pts[i + 1] ?? p;
      return `C${f(b[0] + (p[0] - a[0]) / 6)},${f(b[1] + (p[1] - a[1]) / 6)} ${f(p[0] - (c[0] - b[0]) / 6)},${f(p[1] - (c[1] - b[1]) / 6)} ${f(p[0])},${f(p[1])}`;
    })
    .join("");
}

/** Single-metric line with a volt gradient, labelled peak and low markers, an average guide and a crosshair readout on hover or arrow keys. */
export function GradientMetricChart({
  title = "Daily signups",
  values = VALUES,
  labels = LABELS,
  formatValue = (n) => Math.round(n).toLocaleString("en-US"),
  caption = "Last 30 days",
  className,
}: GradientMetricChartProps) {
  const reduce = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "-40px" });
  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [active, setActive] = React.useState<number | null>(null);

  const n = values.length;
  const min = Math.min(...values);
  const max = Math.max(...values);
  const { lo, hi, ticks } = domain(min, max);
  const avg = values.reduce((a, b) => a + b, 0) / Math.max(n, 1);
  const iMin = values.indexOf(min);
  const iMax = values.indexOf(max);
  const xp = (i: number) => (n > 1 ? (i / (n - 1)) * 100 : 50);
  const yp = (v: number) => (1 - (v - lo) / (hi - lo || 1)) * 100;
  const pts = values.map((v, i) => [xp(i), yp(v)] as [number, number]);
  const line = smooth(pts);
  const last = values[n - 1] ?? 0;
  const change = values[0] ? (last - values[0]) / values[0] : 0;

  function onMove(e: React.PointerEvent<HTMLDivElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    setActive(Math.max(0, Math.min(n - 1, Math.round(((e.clientX - r.left) / r.width) * (n - 1)))));
  }
  function onKey(e: React.KeyboardEvent) {
    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
      e.preventDefault();
      setActive((a) => Math.max(0, Math.min(n - 1, (a ?? n - 1) + (e.key === "ArrowRight" ? 1 : -1))));
    } else if (e.key === "Home" || e.key === "End") {
      e.preventDefault();
      setActive(e.key === "Home" ? 0 : n - 1);
    } else if (e.key === "Escape") setActive(null);
  }

  const marker = (i: number, kind: "Peak" | "Low") => (
    <motion.div
      key={kind}
      aria-hidden
      initial={{ opacity: 0, y: kind === "Peak" ? 6 : -6 }}
      animate={inView ? { opacity: active === null ? 1 : 0.35, y: 0 } : undefined}
      transition={{ delay: reduce ? 0 : 1.1, type: "spring", stiffness: 320, damping: 26 }}
      className="pointer-events-none absolute"
      style={{ left: `${xp(i)}%`, top: `${yp(values[i] ?? 0)}%` }}
    >
      <span className="absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface-raised bg-ink" />
      <span
        className={cn(
          "absolute whitespace-nowrap rounded-full border border-border bg-surface-raised px-2 py-0.5 text-[11px] shadow-sm",
          kind === "Peak" ? "bottom-2.5" : "top-2.5",
          xp(i) > 75 ? "right-0 translate-x-3" : xp(i) < 25 ? "left-0 -translate-x-3" : "-translate-x-1/2",
        )}
      >
        <span className="text-ink-subtle">{kind} </span>
        <span className="font-mono font-semibold tabular-nums text-ink">{formatValue(values[i] ?? 0)}</span>
        <span className="text-ink-subtle"> · {labels[i]}</span>
      </span>
    </motion.div>
  );

  return (
    <figure className={cn("@container w-full rounded-xl border border-border bg-surface-raised p-4 shadow-sm sm:p-5", className)}>
      <figcaption className="flex flex-wrap items-end justify-between gap-3">
        <div>
          <p className="text-[13px] font-medium text-ink-muted">{title}</p>
          <p className="mt-1 font-display text-[32px] font-semibold leading-none tracking-[-0.8px] tabular-nums text-ink">{formatValue(active === null ? last : values[active] ?? 0)}</p>
          <p className="mt-1.5 text-[12px] text-ink-subtle">{active === null ? caption : labels[active]}</p>
        </div>
        <p className={cn("inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[12px] font-semibold tabular-nums", change >= 0 ? "bg-volt-soft text-success" : "bg-surface text-danger")}>
          {change >= 0 ? <ArrowUp aria-hidden className="size-3.5" /> : <ArrowDown aria-hidden className="size-3.5" />}
          {change >= 0 ? "Up" : "Down"} {Math.abs(change * 100).toFixed(1)}%
          <span className="font-normal text-ink-subtle">over period</span>
        </p>
      </figcaption>

      <div className="mt-8 grid grid-cols-[2.25rem_minmax(0,1fr)] gap-x-2">
        <div aria-hidden className="relative h-48 @md:h-56">
          {ticks.map((t) => (
            <span key={t} className="absolute right-0 -translate-y-1/2 font-mono text-[10.5px] tabular-nums text-ink-subtle" style={{ top: `${yp(t)}%` }}>
              {formatValue(t)}
            </span>
          ))}
        </div>
        <div
          ref={ref}
          tabIndex={0}
          role="group"
          aria-label={`${title} chart. Use left and right arrow keys to read each day.`}
          onPointerMove={onMove}
          onPointerLeave={() => setActive(null)}
          onFocus={() => setActive((a) => a ?? n - 1)}
          onBlur={() => setActive(null)}
          onKeyDown={onKey}
          className="relative h-48 touch-none rounded-xs outline-none focus-visible:shadow-[var(--focus-ring)] @md:h-56"
        >
          <svg
            role="img"
            aria-label={`${title}, ${caption}: from ${formatValue(values[0] ?? 0)} to ${formatValue(last)}. Peak ${formatValue(max)} on ${labels[iMax]}, low ${formatValue(min)} on ${labels[iMin]}, average ${formatValue(avg)}.`}
            viewBox="0 0 100 100"
            preserveAspectRatio="none"
            className="absolute inset-0 size-full overflow-visible"
          >
            <defs>
              <linearGradient id={`${uid}-fill`} x1="0" x2="0" y1="0" y2="1">
                <stop offset="0%" style={{ stopColor: "var(--volt-ink)", stopOpacity: 0.3 }} />
                <stop offset="100%" style={{ stopColor: "var(--volt-ink)", stopOpacity: 0 }} />
              </linearGradient>
              <clipPath id={`${uid}-reveal`}>
                <motion.rect x={-2} y={-10} height={120} initial={{ width: reduce ? 104 : 0 }} animate={{ width: inView || reduce ? 104 : 0 }} transition={{ duration: reduce ? 0 : 1.3, ease: [0.65, 0, 0.35, 1] }} />
              </clipPath>
            </defs>
            {ticks.map((t, i) => (
              <line key={t} x1={0} x2={100} y1={yp(t)} y2={yp(t)} className="stroke-border" strokeDasharray={i === 0 ? undefined : "2 4"} vectorEffect="non-scaling-stroke" />
            ))}
            <line x1={0} x2={100} y1={yp(avg)} y2={yp(avg)} className="stroke-ink-subtle" strokeDasharray="6 4" vectorEffect="non-scaling-stroke" />
            <g clipPath={`url(#${uid}-reveal)`}>
              <path d={`${line}L100,100L0,100Z`} fill={`url(#${uid}-fill)`} />
              <path d={line} fill="none" style={{ stroke: "var(--volt-ink)" }} strokeWidth={2.25} strokeLinecap="round" vectorEffect="non-scaling-stroke" />
            </g>
            {active !== null && <line x1={xp(active)} x2={xp(active)} y1={0} y2={100} className="stroke-ink-subtle" strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />}
          </svg>

          <span aria-hidden className="absolute right-1 -translate-y-full pb-0.5 font-mono text-[10.5px] text-ink-muted" style={{ top: `${yp(avg)}%` }}>
            Avg {formatValue(avg)}
          </span>
          {marker(iMax, "Peak")}
          {marker(iMin, "Low")}

          <motion.span
            aria-hidden
            initial={{ opacity: 0, scale: 0 }}
            animate={inView ? { opacity: 1, scale: 1 } : undefined}
            transition={{ delay: reduce ? 0 : 1.2, type: "spring", stiffness: 400, damping: 20 }}
            className="pointer-events-none absolute -ml-1.5 -mt-1.5 size-3 rounded-full bg-volt-ink ring-4 ring-volt-soft"
            style={{ left: "100%", top: `${yp(last)}%` }}
          />
          <AnimatePresence>
            {active !== null && (
              <motion.span
                key="dot"
                aria-hidden
                initial={{ opacity: 0, left: `${xp(active)}%`, top: `${yp(values[active] ?? 0)}%` }}
                animate={{ opacity: 1, left: `${xp(active)}%`, top: `${yp(values[active] ?? 0)}%` }}
                exit={{ opacity: 0 }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }}
                className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface-raised bg-volt-ink shadow-sm"
              />
            )}
          </AnimatePresence>
        </div>
        <div />
        <div aria-hidden className="mt-2 flex justify-between font-mono text-[10.5px] text-ink-subtle">
          <span>{labels[0]}</span>
          <span className="hidden @md:inline">{labels[Math.floor((n - 1) / 2)]}</span>
          <span>{labels[n - 1]}</span>
        </div>
      </div>
      <p className="sr-only" aria-live="polite">
        {active !== null ? `${labels[active]}: ${formatValue(values[active] ?? 0)}` : ""}
      </p>
    </figure>
  );
}

```

### components/examples/line-charts/period-comparison.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ComparisonPeriod {
  /** Tab label, e.g. "Month". */
  key: string;
  /** Point labels, same length as the series. Every period should use the same point count so lines can morph. */
  labels: string[];
  current: number[];
  previous: number[];
  /** Wording for the comparison, e.g. "last month". */
  previousLabel: string;
}

export interface PeriodComparisonChartProps {
  title?: string;
  periods?: ComparisonPeriod[];
  defaultPeriod?: string;
  formatValue?: (n: number) => string;
  className?: string;
}

const cum = (xs: number[]) => xs.reduce<number[]>((acc, x) => [...acc, (acc[acc.length - 1] ?? 0) + x], []);
const PERIODS: ComparisonPeriod[] = [
  { key: "Week", previousLabel: "last week", labels: ["Mon 9a", "Mon 6p", "Tue 9a", "Tue 6p", "Wed 9a", "Wed 6p", "Thu 9a", "Thu 6p", "Fri 9a", "Fri 6p", "Sat", "Sun"], current: cum([1.2, 1.6, 1.4, 1.9, 1.5, 2.1, 1.7, 2.4, 1.9, 2.2, 1.1, 0.9]), previous: cum([1.1, 1.3, 1.5, 1.4, 1.6, 1.7, 1.5, 1.9, 1.8, 2.0, 1.2, 1.0]) },
  { key: "Month", previousLabel: "last month", labels: ["Sep 1", "Sep 4", "Sep 6", "Sep 9", "Sep 11", "Sep 14", "Sep 16", "Sep 19", "Sep 21", "Sep 24", "Sep 26", "Sep 29"], current: cum([5.2, 6.1, 5.8, 7.4, 6.6, 8.1, 7.2, 7.9, 6.8, 8.6, 7.7, 6.9]), previous: cum([5.6, 5.9, 6.2, 6.4, 6.0, 6.9, 6.5, 7.0, 6.3, 7.1, 6.8, 6.1]) },
  { key: "Quarter", previousLabel: "last quarter", labels: ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8", "W9", "W10", "W11", "W12"], current: cum([17.4, 18.2, 16.9, 19.6, 21.3, 20.1, 22.4, 21.8, 23.5, 24.2, 22.9, 25.7]), previous: cum([18.1, 18.6, 19.0, 18.4, 19.7, 20.3, 19.9, 20.8, 21.2, 20.6, 21.9, 22.4]) },
];

function niceTop(max: number, count = 4) {
  const raw = Math.max(max, 1e-6) / count;
  const mag = 10 ** Math.floor(Math.log10(raw));
  const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
  return Array.from({ length: count + 1 }, (_, i) => i * step);
}

function smooth(pts: [number, number][], move = true) {
  const f = (n: number) => n.toFixed(2);
  return pts
    .map((p, i) => {
      if (i === 0) return `${move ? "M" : "L"}${f(p[0])},${f(p[1])}`;
      const a = pts[i - 2] ?? pts[i - 1];
      const b = pts[i - 1];
      const c = pts[i + 1] ?? p;
      return `C${f(b[0] + (p[0] - a[0]) / 6)},${f(b[1] + (p[1] - a[1]) / 6)} ${f(p[0] - (c[0] - b[0]) / 6)},${f(p[1] - (c[1] - b[1]) / 6)} ${f(p[0])},${f(p[1])}`;
    })
    .join("");
}

/** This period against the last: solid volt vs dashed neutral, the gap between them shaded, period tabs that morph both lines with a spring. */
export function PeriodComparisonChart({ title = "Cumulative bookings", periods = PERIODS, defaultPeriod = "Month", formatValue = (n) => `$${n.toFixed(1)}k`, className }: PeriodComparisonChartProps) {
  const reduce = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "-40px" });
  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [key, setKey] = React.useState(defaultPeriod);
  const [active, setActive] = React.useState<number | null>(null);
  const p = periods.find((x) => x.key === key) ?? periods[0];
  const n = p.labels.length;

  const ticks = niceTop(Math.max(...p.current, ...p.previous));
  const top = ticks[ticks.length - 1] || 1;
  const xp = (i: number) => (n > 1 ? (i / (n - 1)) * 100 : 50);
  const yp = (v: number) => (1 - v / top) * 100;
  const cur = p.current.map((v, i) => [xp(i), yp(v)] as [number, number]);
  const prev = p.previous.map((v, i) => [xp(i), yp(v)] as [number, number]);
  const dCur = smooth(cur);
  const dPrev = smooth(prev);
  const dGap = `${dCur}${smooth([...prev].reverse(), false)}Z`;
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 180, damping: 28 };

  const total = p.current[n - 1] ?? 0;
  const before = p.previous[n - 1] ?? 0;
  const delta = before ? (total - before) / before : 0;
  const up = delta >= 0;

  function onMove(e: React.PointerEvent<HTMLDivElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    setActive(Math.max(0, Math.min(n - 1, Math.round(((e.clientX - r.left) / r.width) * (n - 1)))));
  }
  function onKey(e: React.KeyboardEvent) {
    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
      e.preventDefault();
      setActive((a) => Math.max(0, Math.min(n - 1, (a ?? n - 1) + (e.key === "ArrowRight" ? 1 : -1))));
    } else if (e.key === "Escape") setActive(null);
  }

  const a = active ?? null;
  const aCur = a !== null ? p.current[a] ?? 0 : 0;
  const aPrev = a !== null ? p.previous[a] ?? 0 : 0;

  return (
    <figure className={cn("@container w-full rounded-xl border border-border bg-surface-raised p-4 shadow-sm sm:p-5", className)}>
      <div className="flex flex-wrap items-start justify-between gap-3">
        <figcaption>
          <p className="text-[13px] font-medium text-ink-muted">{title}</p>
          <p className="mt-1 font-display text-[30px] font-semibold leading-none tracking-[-0.8px] tabular-nums text-ink">{formatValue(total)}</p>
          <p className="mt-2 flex flex-wrap items-center gap-x-1.5 text-[12px]">
            <span className={cn("inline-flex items-center gap-1 font-semibold", up ? "text-success" : "text-danger")}>
              {up ? <TrendingUp aria-hidden className="size-3.5" /> : <TrendingDown aria-hidden className="size-3.5" />}
              {up ? "Ahead" : "Behind"} {Math.abs(delta * 100).toFixed(1)}%
            </span>
            <span className="text-ink-subtle">
              vs {formatValue(before)} {p.previousLabel}
            </span>
          </p>
        </figcaption>
        <div role="group" aria-label="Period" className="flex rounded-full bg-surface p-0.5">
          {periods.map((x) => (
            <button
              key={x.key}
              type="button"
              aria-pressed={x.key === p.key}
              onClick={() => setKey(x.key)}
              className={cn("relative h-7 rounded-full px-3 text-[12px] font-medium outline-none focus-visible:shadow-[var(--focus-ring)]", x.key === p.key ? "text-ink" : "text-ink-subtle hover:text-ink")}
            >
              {x.key === p.key && <motion.span layoutId={`${uid}-tab`} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 32 }} className="absolute inset-0 rounded-full bg-surface-raised shadow-sm" />}
              <span className="relative">{x.key}</span>
            </button>
          ))}
        </div>
      </div>

      <div className="mt-5 grid grid-cols-[2.75rem_minmax(0,1fr)] gap-x-2">
        <div aria-hidden className="relative h-48 @md:h-56">
          {ticks.map((t, i) => (
            <span key={i} className="absolute right-0 -translate-y-1/2 font-mono text-[10.5px] tabular-nums text-ink-subtle" style={{ top: `${(1 - i / (ticks.length - 1)) * 100}%` }}>
              {t ? formatValue(t).replace(".0k", "k") : "0"}
            </span>
          ))}
        </div>
        <div
          ref={ref}
          tabIndex={0}
          role="group"
          aria-label={`${title} chart. Use left and right arrow keys to compare each point.`}
          onPointerMove={onMove}
          onPointerLeave={() => setActive(null)}
          onFocus={() => setActive((v) => v ?? n - 1)}
          onBlur={() => setActive(null)}
          onKeyDown={onKey}
          className="relative h-48 touch-none rounded-xs outline-none focus-visible:shadow-[var(--focus-ring)] @md:h-56"
        >
          <svg role="img" aria-label={`${title} this ${p.key.toLowerCase()}: ${formatValue(total)}, ${up ? "up" : "down"} ${Math.abs(delta * 100).toFixed(1)}% on ${p.previousLabel} (${formatValue(before)}).`} viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible">
            <defs>
              <clipPath id={`${uid}-reveal`}>
                <motion.rect x={-2} y={-10} height={120} initial={{ width: reduce ? 104 : 0 }} animate={{ width: inView || reduce ? 104 : 0 }} transition={{ duration: reduce ? 0 : 1.2, ease: [0.65, 0, 0.35, 1] }} />
              </clipPath>
            </defs>
            {ticks.map((_, i) => (
              <line key={i} x1={0} x2={100} y1={(i / (ticks.length - 1)) * 100} y2={(i / (ticks.length - 1)) * 100} className="stroke-border" strokeDasharray={i === ticks.length - 1 ? undefined : "2 4"} vectorEffect="non-scaling-stroke" />
            ))}
            <g clipPath={`url(#${uid}-reveal)`}>
              <motion.path initial={false} animate={{ d: dGap }} transition={spring} style={{ fill: "var(--volt-soft)" }} />
              <motion.path initial={false} animate={{ d: dPrev }} transition={spring} fill="none" className="stroke-ink-subtle" strokeWidth={1.75} strokeDasharray="5 4" vectorEffect="non-scaling-stroke" />
              <motion.path initial={false} animate={{ d: dCur }} transition={spring} fill="none" style={{ stroke: "var(--volt-ink)" }} strokeWidth={2.5} strokeLinecap="round" vectorEffect="non-scaling-stroke" />
            </g>
            {a !== null && <line x1={xp(a)} x2={xp(a)} y1={0} y2={100} className="stroke-ink-subtle" strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />}
          </svg>

          {a !== null && (
            <>
              <motion.span aria-hidden initial={false} animate={{ left: `${xp(a)}%`, top: `${yp(aPrev)}%` }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }} className="pointer-events-none absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-ink-subtle bg-surface-raised" />
              <motion.span aria-hidden initial={false} animate={{ left: `${xp(a)}%`, top: `${yp(aCur)}%` }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }} className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface-raised bg-volt-ink" />
            </>
          )}
          <AnimatePresence>
            {a !== null && (
              <motion.div
                key="tip"
                aria-hidden
                initial={{ opacity: 0, scale: 0.96, left: `${xp(a)}%` }}
                animate={{ opacity: 1, scale: 1, left: `${xp(a)}%` }}
                exit={{ opacity: 0, transition: { duration: 0.1 } }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 480, damping: 36 }}
                style={{ x: xp(a) > 55 ? "calc(-100% - 12px)" : "12px" }}
                className="pointer-events-none absolute top-1 z-10 min-w-40 rounded-sm border border-border bg-surface-raised px-2.5 py-2 text-xs shadow-md"
              >
                <p className="mb-1 font-medium text-ink-muted">{p.labels[a]}</p>
                <p className="flex justify-between gap-4">
                  <span className="text-ink-muted">This {p.key.toLowerCase()}</span>
                  <span className="font-mono font-semibold tabular-nums text-ink">{formatValue(aCur)}</span>
                </p>
                <p className="flex justify-between gap-4">
                  <span className="text-ink-subtle">Previous</span>
                  <span className="font-mono tabular-nums text-ink-muted">{formatValue(aPrev)}</span>
                </p>
                <p className={cn("mt-1 border-t border-border pt-1 text-right font-mono font-semibold tabular-nums", aCur >= aPrev ? "text-success" : "text-danger")}>
                  {aCur >= aPrev ? "+" : "−"}
                  {formatValue(Math.abs(aCur - aPrev))}
                </p>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
        <div />
        <div aria-hidden className="relative mt-2 h-4">
          {p.labels.map((l, i) =>
            i % 3 === 0 || i === n - 1 ? (
              <span key={i} className={cn("absolute whitespace-nowrap font-mono text-[10.5px] text-ink-subtle", i === 0 ? "" : i === n - 1 ? "-translate-x-full" : "-translate-x-1/2", i === n - 1 && "hidden @md:block")} style={{ left: `${xp(i)}%` }}>
                {l}
              </span>
            ) : null,
          )}
        </div>
      </div>
      <div className="mt-3 flex flex-wrap gap-4 text-[12px] text-ink-muted">
        <span className="inline-flex items-center gap-1.5">
          <span aria-hidden className="h-[3px] w-4 rounded-full bg-volt-ink" />
          This {p.key.toLowerCase()}
        </span>
        <span className="inline-flex items-center gap-1.5">
          <span aria-hidden className="w-4 border-t-2 border-dashed border-ink-subtle" />
          {p.previousLabel.charAt(0).toUpperCase() + p.previousLabel.slice(1)}
        </span>
      </div>
      <p className="sr-only" aria-live="polite">
        {a !== null ? `${p.labels[a]}: ${formatValue(aCur)} this ${p.key.toLowerCase()}, ${formatValue(aPrev)} ${p.previousLabel}.` : ""}
      </p>
    </figure>
  );
}

```

### components/examples/line-charts/step-annotations.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface StepAnnotation {
  /** Index of the point the note belongs to. */
  index: number;
  title: string;
  body: string;
}

export interface StepAnnotationsChartProps {
  title?: string;
  subtitle?: string;
  labels?: string[];
  /** Value held from each label until the next. */
  values?: number[];
  annotations?: StepAnnotation[];
  /** Optional plan limit drawn as a dashed ceiling. */
  limit?: number;
  unit?: string;
  className?: string;
}

const LABELS = ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"];
const VALUES = [14, 14, 18, 18, 18, 31, 31, 34, 34, 27, 29, 38];
const NOTES: StepAnnotation[] = [
  { index: 2, title: "Support team onboarded", body: "Four agents added for the holiday queue." },
  { index: 5, title: "Design moved to the workspace", body: "Thirteen seats migrated from the old tool." },
  { index: 9, title: "Contractors offboarded", body: "Seven guest seats released after the launch." },
  { index: 11, title: "Annual plan renewed", body: "Limit raised to 40 seats at the new rate." },
];

/** Step line for values that change in jumps, with numbered annotations you can hover, focus or step through to read what moved the line. */
export function StepAnnotationsChart({
  title = "Active seats",
  subtitle = "Workspace members billed each month",
  labels = LABELS,
  values = VALUES,
  annotations = NOTES,
  limit = 40,
  unit = "seats",
  className,
}: StepAnnotationsChartProps) {
  const reduce = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "-40px" });
  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [active, setActive] = React.useState<number | null>(null);
  const btns = React.useRef<(HTMLButtonElement | null)[]>([]);

  const n = values.length;
  const top = Math.ceil((Math.max(...values, limit) * 1.1) / 10) * 10;
  const ticks = [0, top / 2, top];
  const xp = (i: number) => (i / n) * 100;
  const yp = (v: number) => (1 - v / top) * 100;
  const d = values.map((v, i) => `${i ? `H${xp(i).toFixed(2)}V` : `M0,`}${yp(v).toFixed(2)}`).join("") + `H100`;
  const note = active !== null ? annotations[active] : null;

  function onKey(e: React.KeyboardEvent, i: number) {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    e.preventDefault();
    const next = (i + (e.key === "ArrowRight" ? 1 : -1) + annotations.length) % annotations.length;
    btns.current[next]?.focus();
  }

  return (
    <figure className={cn("@container w-full rounded-xl border border-border bg-surface-raised p-4 shadow-sm sm:p-5", className)}>
      <figcaption className="flex flex-wrap items-end justify-between gap-2">
        <div>
          <p className="text-[15px] font-semibold text-ink">{title}</p>
          <p className="text-[12.5px] text-ink-subtle">{subtitle}</p>
        </div>
        <p className="text-[12px] text-ink-muted">
          <span className="font-mono text-[18px] font-semibold tabular-nums text-ink">{values[n - 1]}</span> of {limit} {unit}
        </p>
      </figcaption>

      <div className="mt-6 grid grid-cols-[1.75rem_minmax(0,1fr)] gap-x-2">
        <div aria-hidden className="relative h-44 @md:h-52">
          {ticks.map((t) => (
            <span key={t} className="absolute right-0 -translate-y-1/2 font-mono text-[10.5px] tabular-nums text-ink-subtle" style={{ top: `${yp(t)}%` }}>
              {t}
            </span>
          ))}
        </div>
        <div ref={ref} className="relative h-44 @md:h-52" onPointerLeave={() => setActive(null)}>
          <svg
            role="img"
            aria-label={`${title}: ${values[0]} in ${labels[0]} to ${values[n - 1]} in ${labels[n - 1]}, against a limit of ${limit}. ${annotations.length} annotated changes listed below.`}
            viewBox="0 0 100 100"
            preserveAspectRatio="none"
            className="absolute inset-0 size-full overflow-visible"
          >
            <defs>
              <linearGradient id={`${uid}-fill`} x1="0" x2="0" y1="0" y2="1">
                <stop offset="0%" style={{ stopColor: "var(--volt-ink)", stopOpacity: 0.18 }} />
                <stop offset="100%" style={{ stopColor: "var(--volt-ink)", stopOpacity: 0 }} />
              </linearGradient>
              <clipPath id={`${uid}-reveal`}>
                <motion.rect x={-2} y={-10} height={120} initial={{ width: reduce ? 104 : 0 }} animate={{ width: inView || reduce ? 104 : 0 }} transition={{ duration: reduce ? 0 : 1.3, ease: [0.65, 0, 0.35, 1] }} />
              </clipPath>
            </defs>
            {ticks.map((t) => (
              <line key={t} x1={0} x2={100} y1={yp(t)} y2={yp(t)} className="stroke-border" strokeDasharray={t ? "2 4" : undefined} vectorEffect="non-scaling-stroke" />
            ))}
            <line x1={0} x2={100} y1={yp(limit)} y2={yp(limit)} className="stroke-ink-muted" strokeDasharray="6 4" vectorEffect="non-scaling-stroke" />
            <g clipPath={`url(#${uid}-reveal)`}>
              <path d={`${d}V100H0Z`} fill={`url(#${uid}-fill)`} />
              <path d={d} fill="none" style={{ stroke: "var(--volt-ink)" }} strokeWidth={2.25} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
            </g>
            {note && <line x1={xp(note.index)} x2={xp(note.index)} y1={0} y2={100} className="stroke-ink-subtle" strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />}
          </svg>
          <span aria-hidden className="absolute left-1 -translate-y-full pb-0.5 text-[10.5px] font-medium text-ink-muted" style={{ top: `${yp(limit)}%` }}>
            Plan limit · {limit}
          </span>

          {annotations.map((a, i) => (
            <motion.button
              key={a.index}
              ref={(el) => {
                btns.current[i] = el;
              }}
              type="button"
              aria-label={`${labels[a.index]}: ${a.title}. ${a.body}`}
              aria-describedby={active === i ? `${uid}-note` : undefined}
              initial={{ opacity: 0, scale: 0.4 }}
              animate={inView || reduce ? { opacity: 1, scale: active === i ? 1.15 : 1 } : undefined}
              transition={{ type: "spring", stiffness: 420, damping: 22, delay: reduce || active !== null ? 0 : 1 + i * 0.12 }}
              onPointerEnter={() => setActive(i)}
              onFocus={() => setActive(i)}
              onBlur={() => setActive(null)}
              onKeyDown={(e) => onKey(e, i)}
              className={cn(
                "absolute -ml-3 -mt-3 grid size-6 place-items-center rounded-full border-2 font-mono text-[11px] font-semibold outline-none focus-visible:shadow-[var(--focus-ring)]",
                active === i ? "border-volt-ink bg-volt text-on-volt" : "border-volt-ink bg-surface-raised text-ink",
              )}
              style={{ left: `${xp(a.index)}%`, top: `${yp(values[a.index] ?? 0)}%` }}
            >
              {i + 1}
            </motion.button>
          ))}

          <AnimatePresence>
            {note && active !== null && (
              <motion.div
                key={active}
                id={`${uid}-note`}
                role="note"
                initial={{ opacity: 0, y: 6, filter: "blur(6px)" }}
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                exit={{ opacity: 0, filter: "blur(6px)", transition: { duration: 0.12 } }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 30 }}
                className={cn("pointer-events-none absolute bottom-2 z-10 w-56 rounded-sm border border-border bg-surface-raised p-2.5 shadow-md", xp(note.index) > 50 ? "-translate-x-[calc(100%+16px)]" : "translate-x-4")}
                style={{ left: `${xp(note.index)}%` }}
              >
                <p className="font-mono text-[10.5px] uppercase tracking-wide text-ink-subtle">
                  {labels[note.index]} · {values[note.index]} {unit}
                </p>
                <p className="mt-0.5 text-[13px] font-semibold text-ink">{note.title}</p>
                <p className="mt-0.5 text-[12px] leading-[17px] text-ink-muted">{note.body}</p>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
        <div />
        <div aria-hidden className="relative mt-2 h-4">
          {labels.map((l, i) => (
            <span key={l + i} className={cn("absolute -translate-x-1/2 font-mono text-[10.5px] text-ink-subtle", i % 2 === 1 && "hidden @md:block")} style={{ left: `${xp(i + 0.5)}%` }}>
              {l}
            </span>
          ))}
        </div>
      </div>

      <ol className="mt-4 grid gap-1 @md:grid-cols-2">
        {annotations.map((a, i) => (
          <li key={a.index} onPointerEnter={() => setActive(i)} onPointerLeave={() => setActive(null)} className={cn("flex items-start gap-2 rounded-sm px-2 py-1.5 transition-colors", active === i ? "bg-surface-hover" : "")}>
            <span aria-hidden className={cn("mt-px grid size-5 shrink-0 place-items-center rounded-full font-mono text-[10.5px] font-semibold transition-colors", active === i ? "bg-volt text-on-volt" : "bg-surface text-ink-muted")}>
              {i + 1}
            </span>
            <span className="min-w-0 text-[12.5px] leading-[18px]">
              <span className="font-medium text-ink">{a.title}</span>
              <span className="text-ink-subtle"> · {labels[a.index]}</span>
            </span>
          </li>
        ))}
      </ol>
    </figure>
  );
}

```
