# Editorial Charts

> A four-card editorial chart set — rung bars, a hairline dot-line, a tick-ring dial, and a dot waffle — drawn ink-on-paper by TanStack Charts, with spring-staggered entrances and quarterly data that morphs on a loop.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/editorial-charts).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/editorial-charts
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/editorial-charts-light-7ce617f2fb475310.png
- Registry item: https://uicapsule.com/r/editorial-charts.json
- Categories: data, minimal
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/editorial-charts.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tanstack/charts`

## Files

### `/charts/dot-waffle.tsx`

```tsx
"use client";

import { defineChart, dot } from "@tanstack/charts";
import { motion } from "@tanstack/charts/motion";
import { Chart } from "@tanstack/charts/react/core";
import { scaleLinear } from "@tanstack/charts/scales/linear";
import { useMemo } from "react";

import type { MixRow } from "../lib/data";
import { SPRING } from "../lib/tokens";

const renderer = motion({ initial: "always", transition: SPRING });

interface Cell {
  id: string;
  col: number;
  row: number;
  plan: string;
}

// A hundred dots, one per percent. Cells fill left to right, top to bottom;
// keys carry the plan so a shifted percent pops out and back in.
export const DotWaffle = ({
  mix,
  reveal,
  size,
  ladder,
}: {
  mix: MixRow[];
  /** 0 → 1 entrance progress; the grid inks in dot by dot. */
  reveal: number;
  size: number;
  ladder: readonly string[];
}) => {
  const revealed = Math.min(100, Math.floor(reveal * 101));

  const definition = useMemo(() => {
    const bounds: number[] = [];
    let acc = 0;
    for (const m of mix) {
      acc += m.share;
      bounds.push(acc);
    }
    const cells: Cell[] = Array.from({ length: revealed }, (_, i) => {
      const slot = bounds.findIndex((b) => i < b);
      const plan = mix[slot === -1 ? mix.length - 1 : slot]?.plan ?? "";
      // Fill left → right, top → bottom, so the darkest plan starts where the
      // legend starts reading.
      return { col: i % 10, id: `${i}:${plan}`, plan, row: 9 - Math.floor(i / 10) };
    });
    return defineChart({
      color: { domain: mix.map((m) => m.plan), range: [...ladder] },
      guides: false,
      margin: 0,
      marks: [
        dot(cells, {
          color: "plan",
          key: "id",
          r: size / 30,
          x: "col",
          y: "row",
        }),
      ],
      scales: {
        x: { scale: scaleLinear().domain([-0.65, 9.65]) },
        y: { scale: scaleLinear().domain([-0.65, 9.65]) },
      },
    });
  }, [mix, revealed, size, ladder]);

  return (
    <Chart
      definition={definition}
      renderer={renderer}
      width={size}
      height={size}
      ariaLabel="New accounts by plan, one dot per percent"
    />
  );
};
```

### `/charts/hairline-line.tsx`

```tsx
"use client";

import { defineChart, dot, lineY, text, tickX } from "@tanstack/charts";
import { motion } from "@tanstack/charts/motion";
import { Chart } from "@tanstack/charts/react/core";
import { scaleLinear } from "@tanstack/charts/scales/linear";
import { useMemo } from "react";

import type { DayRow } from "../lib/data";
import { FAINT, INK, MUTED, PAPER, SPRING } from "../lib/tokens";

const renderer = motion({ initial: "always", transition: SPRING });

// One dot = one day, hollow = weekend, barcode floor keeps the calendar
// honest. Days land dot by dot; the hairline threads them once the month is
// complete.
export const HairlineLine = ({
  days,
  reveal,
  yMax,
  height,
  initialWidth,
}: {
  days: DayRow[];
  /** 0 → 1 entrance progress; days land left to right as it advances. */
  reveal: number;
  yMax: number;
  height: number;
  initialWidth: number;
}) => {
  const revealed = Math.min(days.length, Math.floor(reveal * (days.length + 1)));

  const definition = useMemo(() => {
    const shown = days.slice(0, revealed);
    const complete = revealed >= days.length;
    const weekdays = shown.filter((d) => !d.weekend);
    const weekends = shown.filter((d) => d.weekend);
    let [peak] = days;
    for (const d of days) {
      if (peak === undefined || d.value > peak.value) {
        peak = d;
      }
    }
    const last = days.at(-1);
    // Two callouts crowd each other when the peak already sits near day 30.
    const callouts: DayRow[] = [];
    if (complete && peak !== undefined) {
      callouts.push(peak);
      if (last !== undefined && last.day !== peak.day && peak.day < 26) {
        callouts.push(last);
      }
    }
    return defineChart({
      guides: false,
      margin: 0,
      marks: [
        tickX(shown, {
          key: "day",
          length: 8,
          stroke: FAINT,
          strokeWidth: 1,
          x: "day",
          y: () => 0,
        }),
        ...(complete
          ? [
              lineY(days, {
                key: "day",
                stroke: INK,
                strokeWidth: 1.25,
                x: "day",
                y: "value",
              }),
            ]
          : []),
        dot(weekdays, {
          fill: INK,
          id: "weekday-dots",
          key: "day",
          r: 2.4,
          x: "day",
          y: "value",
        }),
        dot(weekends, {
          fill: PAPER,
          id: "weekend-dots",
          key: "day",
          r: 2.6,
          stroke: INK,
          strokeWidth: 1.1,
          x: "day",
          y: "value",
        }),
        text(callouts, {
          anchor: (d) => (d.day >= 27 ? "end" : "middle"),
          dx: (d) => (d.day >= 27 ? 3 : 0),
          dy: -13,
          fill: INK,
          fontSize: 11.5,
          fontWeight: 800,
          key: "day",
          text: (d) => `${d.value}`,
          x: "day",
          y: "value",
        }),
      ],
      motion: { path: "morph" },
      scales: {
        x: { scale: scaleLinear().domain([1, 30]) },
        y: { scale: scaleLinear().domain([0, yMax * 1.2]) },
      },
    });
  }, [days, revealed, yMax]);

  return (
    <div>
      <Chart
        definition={definition}
        renderer={renderer}
        height={height}
        initialWidth={initialWidth}
        ariaLabel="Daily sign-ups over thirty days"
      />
      <div
        className="relative h-5 pt-1.5 text-[9px] font-semibold tracking-[0.09em]"
        style={{ color: MUTED }}
      >
        <span className="absolute left-0">DAY 1</span>
        <span className="absolute left-[48.3%] -translate-x-1/2">DAY 15</span>
        <span className="absolute right-0">DAY 30</span>
      </div>
    </div>
  );
};
```

### `/charts/rung-bars.tsx`

```tsx
"use client";

import { defineChart, text, tickY } from "@tanstack/charts";
import { motion } from "@tanstack/charts/motion";
import { Chart } from "@tanstack/charts/react/core";
import { scaleLinear } from "@tanstack/charts/scales/linear";
import { scalePoint } from "@tanstack/charts/scales/point";
import { useMemo } from "react";

import type { PlanRow } from "../lib/data";
import { INK, MUTED, SPRING } from "../lib/tokens";

const renderer = motion({ initial: "always", transition: SPRING });

// One rung = $2k of MRR. The bar is a ladder you can count.
const RUNG = 2;

interface Rung {
  id: string;
  plan: string;
  y: number;
  fifth: boolean;
}

export const RungBars = ({
  plans,
  reveal,
  height,
  initialWidth,
}: {
  plans: PlanRow[];
  /** 0 → 1 entrance progress; rungs climb in as it advances. */
  reveal: number;
  height: number;
  initialWidth: number;
}) => {
  const totalRungs = plans.reduce((sum, p) => sum + Math.round(p.value / RUNG), 0);
  const revealed = Math.min(totalRungs, Math.floor(reveal * (totalRungs + 1)));

  const definition = useMemo(() => {
    const rungs: Rung[] = plans.flatMap((p) =>
      Array.from({ length: Math.round(p.value / RUNG) }, (_, i) => ({
        fifth: (i + 1) % 5 === 0,
        id: `${p.plan}-${i}`,
        plan: p.plan,
        y: (i + 1) * RUNG,
      })),
    );
    // The ladder climbs column by column, rung by rung.
    const shown = rungs.slice(0, revealed);
    const done = new Map<string, number>();
    for (const r of shown) {
      done.set(r.plan, (done.get(r.plan) ?? 0) + 1);
    }
    const labeled = plans.filter((p) => (done.get(p.plan) ?? 0) >= Math.round(p.value / RUNG));
    const top = Math.max(...plans.map((p) => p.value)) * 1.36;
    return defineChart({
      guides: false,
      margin: 0,
      marks: [
        tickY(shown, {
          key: "id",
          span: 0.5,
          stroke: (d) => (d.fifth ? INK : "rgba(27,27,25,0.55)"),
          strokeWidth: 1.2,
          x: "plan",
          y: "y",
        }),
        text(labeled, {
          dy: -13,
          fill: INK,
          fontSize: 12,
          fontWeight: 800,
          key: "plan",
          text: (d) => `${d.value}`,
          x: "plan",
          y: "value",
        }),
      ],
      scales: {
        // Fixed domains keep columns and headroom still while marks enter.
        x: {
          scale: scalePoint<string>()
            .domain(plans.map((p) => p.plan))
            .padding(0.5),
        },
        y: { scale: scaleLinear().domain([0, top]) },
      },
    });
  }, [plans, revealed]);

  return (
    <div>
      <Chart
        definition={definition}
        renderer={renderer}
        height={height}
        initialWidth={initialWidth}
        ariaLabel="Monthly recurring revenue by plan, one rung per two thousand dollars"
      />
      <div className="flex" style={{ color: MUTED }}>
        {plans.map((p) => (
          <span
            key={p.plan}
            className="flex-1 pt-1.5 text-center text-[9px] font-semibold tracking-[0.09em]"
          >
            {p.plan}
          </span>
        ))}
      </div>
    </div>
  );
};
```

### `/charts/tick-ring.tsx`

```tsx
"use client";

import { defineChart, vector } from "@tanstack/charts";
import { motion } from "@tanstack/charts/motion";
import { Chart } from "@tanstack/charts/react/core";
import { scaleLinear } from "@tanstack/charts/scales/linear";
import { useMemo } from "react";

import type { ChannelRow } from "../lib/data";
import { SPRING } from "../lib/tokens";

const renderer = motion({ initial: "always", transition: SPRING });

interface RingTick {
  id: number;
  x: number;
  y: number;
  rotate: number;
  channel: string;
  major: boolean;
}

// 100 radial dashes, one per percent, read clockwise from noon. The reveal
// sweeps the dial once; after that each scene twists it a few degrees so the
// update itself is visible motion.
export const TickRing = ({
  channels,
  reveal,
  size,
  twist,
  ladder,
}: {
  channels: ChannelRow[];
  /** 0 → 1 entrance progress; the dial sweeps clockwise as it advances. */
  reveal: number;
  size: number;
  twist: number;
  ladder: readonly string[];
}) => {
  const revealed = Math.min(100, Math.floor(reveal * 101));

  const definition = useMemo(() => {
    const bounds: number[] = [];
    let acc = 0;
    for (const c of channels) {
      acc += c.share;
      bounds.push(acc);
    }
    const shade = new Map(channels.map((c, i) => [c.channel, ladder[i % ladder.length]]));
    const ticks: RingTick[] = Array.from({ length: revealed }, (_, i) => {
      const deg = i * 3.6 + twist;
      const rad = (deg * Math.PI) / 180;
      const slot = bounds.findIndex((b) => i < b);
      const row = channels[slot === -1 ? channels.length - 1 : slot];
      return {
        channel: row?.channel ?? "",
        id: i,
        major: i % 10 === 0,
        rotate: deg + 180,
        x: Math.sin(rad),
        y: Math.cos(rad),
      };
    });
    return defineChart({
      guides: false,
      margin: 0,
      marks: [
        vector(ticks, {
          anchor: "start",
          headLength: 0,
          key: "id",
          length: (d) => (d.major ? 26 : 18),
          rotate: "rotate",
          stroke: (d) => shade.get(d.channel) ?? ladder[0] ?? "#f1f0ec",
          strokeWidth: 2.4,
          x: "x",
          y: "y",
        }),
      ],
      scales: {
        x: { scale: scaleLinear().domain([-1, 1]) },
        y: { scale: scaleLinear().domain([-1, 1]) },
      },
    });
  }, [channels, revealed, twist, ladder]);

  return (
    <Chart
      definition={definition}
      renderer={renderer}
      width={size}
      height={size}
      ariaLabel="Sessions by acquisition channel, one tick per percent"
    />
  );
};
```

### `/editorial-charts.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";

import { DotWaffle } from "./charts/dot-waffle";
import { HairlineLine } from "./charts/hairline-line";
import { RungBars } from "./charts/rung-bars";
import { TickRing } from "./charts/tick-ring";
import { DAY_MAX, SCENES } from "./lib/data";
import { DARK, FAINT, INK, LADDER, MUTED, PAPER } from "./lib/tokens";

const CYCLE_MS = 4600;

const useCountUp = (target: number, ms = 950) => {
  // Starts at 0 (matching the not-yet-inked charts around it) and always
  // resumes from the last painted value, so neither SSR nor a Strict Mode
  // remount can flash the final count or skip the entrance.
  const [shown, setShown] = useState(0);
  const painted = useRef(0);
  useEffect(() => {
    const from = painted.current;
    if (from === target) {
      return;
    }
    let raf = 0;
    const t0 = performance.now();
    const step = (t: number) => {
      const p = Math.min(1, (t - t0) / ms);
      const eased = 1 - (1 - p) ** 3;
      const value = Math.round(from + (target - from) * eased);
      painted.current = value;
      setShown(value);
      if (p < 1) {
        raf = requestAnimationFrame(step);
      }
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [target, ms]);
  return shown;
};

// Entrance clock: 0 → 1 over `ms`, driving how much of each chart is inked.
const useReveal = (active: boolean, ms: number) => {
  const [progress, setProgress] = useState(0);
  useEffect(() => {
    if (!active) {
      return;
    }
    let raf = 0;
    const t0 = performance.now();
    const step = (t: number) => {
      const p = Math.min(1, (t - t0) / ms);
      setProgress(p);
      if (p < 1) {
        raf = requestAnimationFrame(step);
      }
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [active, ms]);
  return progress;
};

const Kicker = ({ children, dark }: { children: string; dark?: boolean }) => (
  <span
    className="inline-block rounded-full border border-dashed px-2.5 py-0.5 text-[8.5px] font-semibold tracking-[0.14em]"
    style={{
      borderColor: dark ? DARK.faint : FAINT,
      color: dark ? DARK.muted : MUTED,
    }}
  >
    {children}
  </span>
);

const CardHead = ({
  kicker,
  title,
  sub,
  dark,
}: {
  kicker: string;
  title: string;
  sub: string;
  dark?: boolean;
}) => (
  <div>
    <Kicker dark={dark}>{kicker}</Kicker>
    <h2
      className="mt-2 text-[16.5px] font-bold tracking-[-0.02em]"
      style={{ color: dark ? DARK.ink : INK }}
    >
      {title}
    </h2>
    <p className="mt-0.5 text-[11.5px]" style={{ color: dark ? DARK.muted : MUTED }}>
      {sub}
    </p>
  </div>
);

const SourceRow = ({ children, dark }: { children: string; dark?: boolean }) => (
  <p
    className="mt-auto pt-3 text-[9px] font-medium tracking-[0.08em]"
    style={{ color: dark ? DARK.faint : FAINT }}
  >
    {children}
  </p>
);

export const EditorialCharts = () => {
  const [scene, setScene] = useState(0);
  // Charts mount after first paint so every visitor gets the full staggered
  // entrance instead of a server-rendered final state.
  const [inked, setInked] = useState(false);
  useEffect(() => {
    const frame = requestAnimationFrame(() => setInked(true));
    const t = setInterval(() => setScene((s) => (s + 1) % SCENES.length), CYCLE_MS);
    return () => {
      cancelAnimationFrame(frame);
      clearInterval(t);
    };
  }, []);

  const reveal = useReveal(inked, 1500);
  const s = SCENES[scene];
  if (!s) {
    throw new RangeError("Editorial chart scene index is out of bounds");
  }
  const sessions = useCountUp(s.sessions);

  return (
    <div
      className="w-[1080px] rounded-[28px] px-9 pt-7 pb-5 shadow-[0_40px_90px_-30px_rgba(0,0,0,0.55)] [font-family:Inter,ui-sans-serif,system-ui,sans-serif]"
      style={{ backgroundColor: PAPER, color: INK }}
    >
      {/* Masthead */}
      <div className="flex items-baseline justify-between pb-4">
        <div className="flex items-baseline gap-3">
          <h1 className="text-[19px] font-bold tracking-[-0.02em]">The quarter, drawn in ink</h1>
          <span className="text-[11.5px]" style={{ color: MUTED }}>
            four marks, one grammar, one spring
          </span>
        </div>
        <div className="flex gap-3 text-[11px] font-bold tracking-[0.06em]">
          {SCENES.map((q, i) => (
            <span
              key={q.quarter}
              className="transition-colors duration-500"
              style={{
                borderBottom: i === scene ? `2px solid ${INK}` : "2px solid transparent",
                color: i === scene ? INK : FAINT,
              }}
            >
              {q.quarter}
            </span>
          ))}
        </div>
      </div>

      <div className="grid grid-cols-2 gap-x-9 gap-y-5">
        {/* Rung bars */}
        <section className="flex min-h-[298px] flex-col">
          <CardHead
            kicker="RUNG BARS · BILLING"
            title="Revenue by plan, rung by rung"
            sub="one rung = $2k of MRR · the bar is a ladder you can count"
          />
          <div className="mt-3 h-[204px]">
            {inked && <RungBars plans={s.plans} reveal={reveal} height={182} initialWidth={480} />}
          </div>
          <SourceRow>TICK MARKS ON A POINT SCALE · STAGGERED SPRING ENTRANCE</SourceRow>
        </section>

        {/* Tick ring — the dark card */}
        <section
          className="flex min-h-[298px] flex-col rounded-[20px] px-6 pt-5 pb-4"
          style={{ backgroundColor: DARK.bg }}
        >
          <CardHead
            dark
            kicker="TICK RING · ACQUISITION"
            title="Where the sessions come from"
            sub="one tick = 1% · reads clockwise from noon · the dial twists each quarter"
          />
          <div className="relative mx-auto mt-1 size-[198px]">
            {inked && (
              <TickRing
                channels={s.channels}
                reveal={reveal}
                size={198}
                twist={scene * 14}
                ladder={DARK.ladder}
              />
            )}
            <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
              <span
                className="text-[30px] font-extrabold tracking-[-0.02em] tabular-nums"
                style={{ color: DARK.ink }}
              >
                {(sessions / 1000).toFixed(1)}k
              </span>
              <span
                className="text-[8.5px] font-semibold tracking-[0.14em]"
                style={{ color: DARK.muted }}
              >
                SESSIONS
              </span>
            </div>
            {s.channels.map((c, i) => {
              // Clockwise from noon: channel order sweeps right side first.
              const spots = [
                "left-full top-3 ml-3",
                "left-full bottom-3 ml-3",
                "right-full bottom-3 mr-3 text-right",
                "right-full top-3 mr-3 text-right",
              ] as const;
              return (
                <div
                  key={c.channel}
                  className={`absolute w-24 text-[9px] font-semibold tracking-[0.1em] ${spots[i % 4]}`}
                  style={{ color: DARK.ladder[i % DARK.ladder.length] }}
                >
                  {c.channel}
                  <span className="block text-[13px] font-extrabold tabular-nums tracking-normal">
                    {c.share}%
                  </span>
                </div>
              );
            })}
          </div>
          <SourceRow dark>100 VECTOR MARKS · ONE SHADE PER CHANNEL · SPRUNG TWIST</SourceRow>
        </section>

        {/* Hairline line */}
        <section className="flex min-h-[286px] flex-col">
          <CardHead
            kicker="HAIRLINE LINE · GROWTH"
            title="Thirty days of sign-ups"
            sub="one dot = one day · hollow = weekend · the floor is a barcode of days"
          />
          <div className="mt-3 h-[184px]">
            {inked && (
              <HairlineLine
                days={s.days}
                reveal={reveal}
                yMax={DAY_MAX}
                height={158}
                initialWidth={480}
              />
            )}
          </div>
          <SourceRow>LINE + DOT + TEXT MARKS · PATH MORPHS BETWEEN QUARTERS</SourceRow>
        </section>

        {/* Dot waffle */}
        <section className="flex min-h-[286px] flex-col">
          <CardHead
            kicker="DOT WAFFLE · PLAN MIX"
            title="What the new accounts pick"
            sub="one dot = 1% of new accounts · a moved percent pops out and back in"
          />
          <div className="mt-2 flex items-center gap-8">
            <div className="size-[182px]">
              {inked && <DotWaffle mix={s.mix} reveal={reveal} size={182} ladder={LADDER} />}
            </div>
            <div className="flex flex-col gap-2.5">
              {s.mix.map((m, i) => (
                <div key={m.plan} className="flex items-center gap-2.5">
                  <span
                    className="size-2.5 rounded-full"
                    style={{ backgroundColor: LADDER[i % LADDER.length] }}
                  />
                  <span
                    className="w-16 text-[9px] font-semibold tracking-[0.1em]"
                    style={{ color: MUTED }}
                  >
                    {m.plan}
                  </span>
                  <span className="text-[13px] font-extrabold tabular-nums">{m.share}%</span>
                </div>
              ))}
            </div>
          </div>
          <SourceRow>DOT MARKS + ORDINAL COLOR SCALE · KEYS CARRY THE PLAN</SourceRow>
        </section>
      </div>

      <p
        className="pt-4 text-center text-[9px] font-medium tracking-[0.08em]"
        style={{ color: FAINT }}
      >
        DEMO DATA · DRAWN BY TANSTACK CHARTS · SPRINGS AND STAGGER BY ITS MOTION RENDERER
      </p>
    </div>
  );
};
```

### `/lib/data.ts`

```ts
// Three quarters of a fictional product, cycled by the set. Deterministic on
// purpose — a refresh must draw the exact same page.

export interface PlanRow {
  plan: string;
  value: number;
}
export interface DayRow {
  day: number;
  value: number;
  weekend: boolean;
}
export interface ChannelRow {
  channel: string;
  share: number;
}
export interface MixRow {
  plan: string;
  share: number;
}

export interface Scene {
  quarter: string;
  /** $k MRR per plan. Keep every value divisible by 2: one rung = $2k, and the
   * rung-bars ladder must count exactly to its label. */
  plans: PlanRow[];
  days: DayRow[];
  channels: ChannelRow[];
  sessions: number;
  mix: MixRow[];
}

/* oxlint-disable no-bitwise -- integer hash: xorshift-multiply needs unsigned 32-bit wraparound */
const hash = (n: number) => {
  let x = (n + 0x9e_37_79_b9) >>> 0;
  x = Math.imul(x ^ (x >>> 16), 0x4_5d_9f_3b) >>> 0;
  x = Math.imul(x ^ (x >>> 16), 0x4_5d_9f_3b) >>> 0;
  return ((x ^ (x >>> 16)) >>> 0) / 0xff_ff_ff_ff;
};
/* oxlint-enable no-bitwise */

const days = (seed: number, base: number, slope: number): DayRow[] =>
  Array.from({ length: 30 }, (_, i) => {
    const weekend = i % 7 === 5 || i % 7 === 6;
    const wiggle = (hash(seed * 1000 + i) - 0.5) * 16;
    const value = Math.max(6, Math.round(base + i * slope + wiggle - (weekend ? 11 : 0)));
    return { day: i + 1, value, weekend };
  });

export const SCENES: Scene[] = [
  {
    channels: [
      { channel: "ORGANIC", share: 37 },
      { channel: "REFERRAL", share: 21 },
      { channel: "SOCIAL", share: 14 },
      { channel: "PAID", share: 28 },
    ],
    days: days(1, 34, 0.9),
    mix: [
      { plan: "FREE", share: 46 },
      { plan: "STARTER", share: 27 },
      { plan: "PRO", share: 17 },
      { plan: "TEAM", share: 10 },
    ],
    plans: [
      { plan: "FREE", value: 38 },
      { plan: "STARTER", value: 28 },
      { plan: "PRO", value: 22 },
      { plan: "TEAM", value: 16 },
      { plan: "SCALE", value: 10 },
    ],
    quarter: "Q1",
    sessions: 41_800,
  },
  {
    channels: [
      { channel: "ORGANIC", share: 41 },
      { channel: "REFERRAL", share: 23 },
      { channel: "SOCIAL", share: 12 },
      { channel: "PAID", share: 24 },
    ],
    days: days(2, 42, 1.15),
    mix: [
      { plan: "FREE", share: 41 },
      { plan: "STARTER", share: 29 },
      { plan: "PRO", share: 19 },
      { plan: "TEAM", share: 11 },
    ],
    plans: [
      { plan: "FREE", value: 44 },
      { plan: "STARTER", value: 32 },
      { plan: "PRO", value: 26 },
      { plan: "TEAM", value: 20 },
      { plan: "SCALE", value: 14 },
    ],
    quarter: "Q2",
    sessions: 48_200,
  },
  {
    channels: [
      { channel: "ORGANIC", share: 46 },
      { channel: "REFERRAL", share: 24 },
      { channel: "SOCIAL", share: 11 },
      { channel: "PAID", share: 19 },
    ],
    days: days(3, 52, 1.4),
    mix: [
      { plan: "FREE", share: 34 },
      { plan: "STARTER", share: 31 },
      { plan: "PRO", share: 22 },
      { plan: "TEAM", share: 13 },
    ],
    plans: [
      { plan: "FREE", value: 52 },
      { plan: "STARTER", value: 36 },
      { plan: "PRO", value: 30 },
      { plan: "TEAM", value: 24 },
      { plan: "SCALE", value: 18 },
    ],
    quarter: "Q3",
    sessions: 56_400,
  },
];

// Shared y ceiling so the line visibly climbs across quarters.
export const DAY_MAX = Math.max(...SCENES.flatMap((s) => s.days.map((d) => d.value)));
```

### `/lib/tokens.ts`

```ts
// Editorial mono tokens — paper ground, charcoal ink, lightness IS the data.
// Inspired by print-editorial data graphics; values are our own.

export const PAPER = "#f1f0ec";
export const INK = "#1b1b19";
export const MUTED = "#8b8a84";
export const FAINT = "#c2c1bb";
export const HAIR = "#dbdad3";

// Ink ladder for multi-series work: most important = darkest.
export const LADDER = ["#1b1b19", "#4a4943", "#8b8a84", "#aeada7", "#d0cfc9"] as const;

// Dark card: ink becomes paper, ladder runs bright → dim.
export const DARK = {
  bg: "#1b1b19",
  faint: "#4c4b45",
  ink: "#f1f0ec",
  ladder: ["#f1f0ec", "#c7c6be", "#93928b", "#5c5b54"] as const,
  muted: "#8b8a84",
};

// One spring for the whole set — quick in, no bounce to speak of.
export const SPRING = {
  damping: 22,
  mass: 1,
  stiffness: 170,
  type: "spring",
} as const;
```

### `/preview.tsx`

```tsx
"use client";

import { EditorialCharts } from "./editorial-charts";

const Preview = () => (
  <main className="flex h-dvh items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_50%_25%,#26272b_0%,#141518_50%,#08090b_100%)] px-5">
    <div className="origin-center scale-[0.94]">
      <EditorialCharts />
    </div>
  </main>
);

export default Preview;
```
