# Glow bars

> A cash-flow chart. The month under the pointer lights up in violet and blue, and a tooltip glides along with it, rolling its figures.

A monthly chart of incomes and outcomes, muted at rest. Point at a month, or move with the arrow keys, and its two bars light up: incomes in a violet-pink glow and outcomes in blue, both with a fine grain and a sheen running up them. The light and the tooltip glide from month to month, and the tooltip’s figures roll to their new values.

A React component by Bruno Delorence: https://brunodelorence.com/components/glow-bars. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Data.

## Anatomy

1. **Title**: Names the card, and labels it for a screen reader together with the first and last period. The subtitle under it says what is compared.
2. **Menu button**: The ⋮ button, a real button labelled “Chart options”: it calls `onMenu`.
3. **Y axis**: Seven labels, $0 to `max`, one on every sixth of it, each centred on its gridline.
4. **Muted bars**: Each period’s pair at rest, incomes left and outcomes right: a soft grey gradient, its top on its value, 4 px above the $0 line.
5. **Lit period**: One glowing pair, a column wide, that slides to the period under the pointer while its bars stretch to the new values. A sheen runs up them once they settle.
6. **Tooltip**: Both figures and the period, riding with the lit pair: lifted to the taller bar, and over to its left near the right edge.
7. **Periods**: The short name under each column (`short` in `months`); the full name is in the tooltip and is what gets announced.
8. **Legend**: The two series’ names, from `series`, with a swatch in each one’s glow.

## When to use

- Two money series side by side over a few periods, money in against money out, where the reader compares one period at a time.
- Between 4 and 12 periods: months, quarters or seasons, each with a short name under its column.
- A dashboard card where the exact figures matter on demand: the tooltip gives both, to the cent, for the lit period.
- A hero chart worth a moment of delight: the lit pair glides from column to column and its digits roll.

## When not to use

- More than two series, or series that are not money. Use a grouped bar chart with its own legend and axis format.
- A long time series (years of days or weeks). Use a line or area chart that can zoom and pan.
- When every value must be readable at once, as in a report or a print. Use a table, or bars with their labels on them.
- Totals that are negative, or a net figure on its own. The bars start at $0; show the net in the subtitle, updated through `onMonthChange`.
- Dense screens with several charts: one glowing card draws the eye, several compete for it.

## Installation

Install it with the shadcn CLI, into components/glow-bars of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/glow-bars.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
'use client';

import { useState } from 'react';
import { GlowBars } from './glow-bars';
import { MONTHS } from './months';

export function CashFlowCard() {
  const [month, setMonth] = useState(2);
  return (
    <GlowBars
      autoplay={false}
      months={MONTHS}
      max={6000}
      defaultMonth={month}
      onMonthChange={setMonth}
    />
  );
}
```

## States

- **Lit period**: The default: March lit, the tooltip on the pair’s right, lifted to its taller bar.
- **First period**: The lit pair at the left edge; the tooltip still opens to its right.
- **Near the right edge**: Past 70% of the way across, the tooltip moves to the other side of the pair so it stays on the card.
- **Few periods**: Four quarters with their own `max` and `series`: the columns share the width, so the bars are wider.
- **Twelve periods**: A full year with one-letter names: narrow columns, the same tooltip.
- **Narrow card**: Under 520 px the type gets smaller and the tooltip stacks its two figures over one date.
- **Light**: With `colorScheme="light"`: a white card whose lit bars cast a soft light of their colour.
- **Dark**: With `colorScheme="dark"`: a dark card; the bars shine on their own, with no light cast.

## Examples

### Quarters

A shop’s year in four quarters: wider bars on a $12k axis, the series named Sales and Costs, and the fourth quarter lit.

```tsx
import { GlowBars } from './glow-bars';
import type { CashMonth } from './months';

const QUARTERS: CashMonth[] = [
  { short: 'Q1', name: 'Q1 2025', income: 6842.3, outcome: 4915.6 },
  { short: 'Q2', name: 'Q2 2025', income: 7960.45, outcome: 5288.1 },
  { short: 'Q3', name: 'Q3 2025', income: 5410.8, outcome: 4702.35 },
  { short: 'Q4', name: 'Q4 2025', income: 9276.9, outcome: 6134.55 },
];

export function Quarters() {
  return (
    <GlowBars
      title="Quarterly sales"
      subtitle="Shop sales vs running costs, 2025"
      months={QUARTERS}
      max={12000}
      series={{ income: 'Sales', outcome: 'Costs' }}
      defaultMonth={3}
      autoplay={false}
    />
  );
}
```

### Full year

Twelve months of a lake cabin’s rentals, with slimmer bars over the months’ initials and August, the busiest, lit at first.

```tsx
import { GlowBars } from './glow-bars';
import type { CashMonth } from './months';

const YEAR: CashMonth[] = [
  { short: 'J', name: 'January 2025', income: 980, outcome: 1236.4 },
  { short: 'F', name: 'February 2025', income: 1240, outcome: 1198.75 },
  { short: 'M', name: 'March 2025', income: 1685.5, outcome: 1154.2 },
  { short: 'A', name: 'April 2025', income: 2410, outcome: 1302.85 },
  { short: 'M', name: 'May 2025', income: 3175.25, outcome: 1388.6 },
  { short: 'J', name: 'June 2025', income: 4260, outcome: 1546.3 },
  { short: 'J', name: 'July 2025', income: 4985.5, outcome: 1712.95 },
  { short: 'A', name: 'August 2025', income: 5040, outcome: 1768.4 },
  { short: 'S', name: 'September 2025', income: 3620.75, outcome: 1421.1 },
  { short: 'O', name: 'October 2025', income: 2290, outcome: 1265.55 },
  { short: 'N', name: 'November 2025', income: 1120, outcome: 1184.9 },
  { short: 'D', name: 'December 2025', income: 1845, outcome: 1327.65 },
];

export function FullYear() {
  return (
    <GlowBars
      title="Lake cabin"
      subtitle="Rental income vs upkeep, 2025"
      months={YEAR}
      defaultMonth={7}
      autoplay={false}
    />
  );
}
```

### Over budget

Six months on a $3k axis in which spending passes earnings twice; September is lit, so the tooltip sits on the taller blue bar.

```tsx
import { GlowBars } from './glow-bars';
import type { CashMonth } from './months';

const BUDGET: CashMonth[] = [
  { short: 'Jul', name: 'July 2025', income: 1860, outcome: 1512.4 },
  { short: 'Aug', name: 'August 2025', income: 1860, outcome: 1698.75 },
  { short: 'Sep', name: 'September 2025', income: 1925, outcome: 2384.6 },
  { short: 'Oct', name: 'October 2025', income: 2045.5, outcome: 1621.3 },
  { short: 'Nov', name: 'November 2025', income: 1925, outcome: 1587.9 },
  { short: 'Dec', name: 'December 2025', income: 2310, outcome: 2486.2 },
];

export function OverBudget() {
  return (
    <GlowBars
      title="Monthly budget"
      subtitle="Earned vs spent, July to December"
      months={BUDGET}
      max={3000}
      series={{ income: 'Earned', outcome: 'Spent' }}
      defaultMonth={2}
      autoplay={false}
    />
  );
}
```

### Net per month

A cash-flow card whose subtitle gives the lit month’s net, incomes less outcomes: onMonthChange keeps it current.

```tsx
'use client';

import { useState } from 'react';
import { GlowBars } from './glow-bars';
import { MONTHS } from './months';

const net = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  signDisplay: 'exceptZero',
});

export function NetPerMonth() {
  const [month, setMonth] = useState(MONTHS.length - 1);
  const { name, income, outcome } = MONTHS[month];
  return (
    <GlowBars
      title="Net cash flow"
      subtitle={`${name}: ${net.format(income - outcome)}`}
      months={MONTHS}
      defaultMonth={month}
      onMonthChange={setMonth}
      autoplay={false}
    />
  );
}
```

## How it works

- The lit pair is a single column of two glowing bars that a transform moves to the active month. Each bar’s height is a clip-path inset and its gradient a background-size, both transitioned, so the light stretches as it glides and no box ever changes size.
- The tooltip travels inside that column, placed by three nested transforms set from CSS variables: one lifts it to the taller bar, one flips it to the other side of the pair near the right edge, and one gives it its own offset. Each digit of its figures is a reel of 0 to 9 that slides to its value.
- The first time the chart comes into view, the bars grow in month after month, with the Web Animations API on clip-path. Once a glide settles, a soft white band runs up the two lit bars. The frosted look comes from a tiled noise texture and an inset rim.
- With a mouse or a pen, the month under the pointer lights up; a touch picks a month while it is down, and a press or a key also announces the month. The month is read from the plot’s box, so it still lands right when the chart is drawn scaled down.
- The demo pointer sweeps from January to July and back to March. The sweep is an animation the compositor plays, and the lit month is worked out from how far along it is rather than from the page’s geometry, so the demo never forces a layout.
- Every colour is a light-dark() token, so the card follows the page’s color-scheme, or the colorScheme prop: white by day, near-black at night, on the same light grid. On white the lit bars are a shade deeper and cast a soft light in their own colour, a drop shadow on a wrapper round each bar, so it follows the bar as it stretches and grows in.

## API reference

- **title** (`string`, default `'Cash Flow Trends'`): The card’s title.
- **subtitle** (`string`, default `'Monthly income vs expenses comparison'`): The line under the title.
- **months** (`CashMonth[]`, default `MONTHS`): The months in order, each with a short name, a full name, and incomes and outcomes in dollars.
- **max** (`number`, default `6000`): The top of the y axis in dollars; there is a gridline at every sixth of it.
- **series** (`{ income: string; outcome: string }`, default `{ income: 'Incomes', outcome: 'Outcomes' }`): The names of the two series, money in and money out, as the legend shows them and screen readers hear them.
- **defaultMonth** (`number`, default `2`): The month lit at first, as its index in months.
- **onMonthChange** (`(index: number) => void`): Called with the index of each month the reader lights.
- **onMenu** (`() => void`): Called when the ⋮ button is pressed.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer sweeps across the months.
- **growIn** (`boolean`, default `true`): The bars grow in, month after month, the first time the chart comes into view; false draws them at their values at once.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The chart is a focusable group with the role description “chart” and a short hint. The arrow keys, Home and End move between months, and each month’s two figures are read out in a polite live region; the demo’s own moves are not announced.
- The ⋮ is a labelled button and the legend is a list. The gridlines, bars, axis labels and the tooltip drawing are hidden from screen readers.
- Focus rings are visible on the chart and on the button. Under reduced motion there is no grow-in, glide, rolling digits, sheen or demo.
- The server renders a complete chart with March lit, so it reads the same without JavaScript.

## Source

### glow-bars/glow-bars.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { MONTHS, type CashMonth } from './months';
import { useAutoplay } from './use-autoplay';
import styles from './glow-bars.module.css';

/*
 * Glow bars: a cash-flow card whose bars light up under the pointer.
 *
 * - Each month is a pair of rounded bars, incomes and outcomes, muted at rest. The month under the
 *   pointer (or picked with the arrow keys) glows: incomes in violet and pink, outcomes in blue
 *   and cyan, with a fine grain and a sheen that runs up them once they settle.
 * - The glow is one pair of bars that travels from month to month, so the light glides rather
 *   than blinks. Its column slides with a transform; each bar's height is a clip-path, and its
 *   gradient a background-size, both transitioned, so the bars stretch while they travel.
 * - The tooltip rides along: a column wide, it is moved with transforms only (across, up to the
 *   taller bar, and over to the other side of the pair near the right edge), from CSS variables.
 *   Its figures roll: each digit is a reel of 0 to 9 that slides to its place.
 * - The bars grow in, month after month, the first time the chart comes into view.
 * - The chart is focusable: the arrow keys, Home and End move between months, and the month's
 *   figures are announced.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the card is white on a
 *   light page and dark on a dark one (the page's color-scheme, or the one the colorScheme prop
 *   sets). On white, the lit bars cast a soft light of their colour: a drop shadow on a wrapper
 *   round each bar, so it follows the bar as it is clipped and grows in. At night it is
 *   transparent: the bars shine on their own.
 *
 * GlowBars is the card on its stage; while the stage is on screen and untouched, a demo pointer
 * sweeps across the months.
 */

/** The y axis' steps: gridlines at every sixth of the maximum. */
const STEPS = 6;
/** How long the bars take to grow in, and the delay from one month to the next, in ms. */
const GROW = 760;
const STAGGER = 70;
/** How long after a month lights up its sheen runs, in ms: once the glide has settled. */
const SHEEN_DELAY = 260;

const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
/** An axis label: $0, $500, $1k, $1.5k … */
const tick = (value: number) =>
  value < 1000 ? `$${Math.round(value)}` : `$${Number((value / 1000).toFixed(1)).toString()}k`;
/** The two series' names, unless the chart is given others. */
const SERIES = { income: 'Incomes', outcome: 'Outcomes' };

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** A figure whose digits roll to their places: each digit is a reel of 0 to 9, moved by CSS. */
function RollingFigure({ text }: { text: string }) {
  const characters = [...text];
  return (
    <span className={styles.figure} data-part="figure">
      {characters.map((character, index) => {
        // Keyed from the right, so that each place keeps its reel when the figure grows a digit.
        const key = characters.length - index;
        if (!/\d/.test(character)) {
          return (
            <span key={key} className={styles.glyph}>
              {character}
            </span>
          );
        }
        return (
          <span key={key} className={styles.digit}>
            <span className={styles.reel} style={{ '--d': character } as CSSProperties}>
              {'0123456789'.split('').map((digit) => (
                <span key={digit}>{digit}</span>
              ))}
            </span>
          </span>
        );
      })}
    </span>
  );
}

export function GlowBars({
  title = 'Cash Flow Trends',
  subtitle = 'Monthly income vs expenses comparison',
  months = MONTHS,
  max = 6000,
  series = SERIES,
  defaultMonth = 2,
  onMonthChange,
  onMenu,
  autoplay = true,
  growIn = true,
  colorScheme,
  className = '',
}: {
  /** The card's title. */
  title?: string;
  /** The line under the title. */
  subtitle?: string;
  /** The months, in order: a short name, a full name, incomes and outcomes in dollars. */
  months?: CashMonth[];
  /** The top of the y axis, in dollars; the axis has a gridline at every sixth of it. */
  max?: number;
  /** The names of the two series, money in and money out: in the legend and when announced. */
  series?: { income: string; outcome: string };
  /** The month lit at first, as its place in `months`. */
  defaultMonth?: number;
  /** Called with the place of each month the reader lights. */
  onMonthChange?: (index: number) => void;
  /** Called when the ⋮ button is pressed. */
  onMenu?: () => void;
  /** While on screen and untouched, a demo pointer sweeps across the months. */
  autoplay?: boolean;
  /** The bars grow in, month after month, the first time the chart comes into view; false draws
   *  them at their values at once. */
  growIn?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Extra class for the root, a stage of its own. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const last = months.length - 1;
  const [active, setActive] = useState(() => Math.min(last, Math.max(0, defaultMonth)));
  /** The lit month as last set, for keys pressed faster than the chart redraws. */
  const current = useRef(active);
  const [announcement, setAnnouncement] = useState('');
  /** Keyboard focus is on the stage: the demo leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const band = useRef<HTMLDivElement>(null);
  const plot = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const stopDemo = useRef<(() => void) | null>(null);
  const hintId = useId();

  const month = months[Math.min(active, last)];
  const share = (value: number) => Math.min(1, Math.max(0, value / max));

  /** Lights a month; the reader's own picks are reported, and announced when `say` is set. */
  const light = (index: number, by: 'reader' | 'demo', say = false) => {
    const next = Math.min(last, Math.max(0, index));
    current.current = next;
    setActive(next);
    if (by === 'demo') return;
    onMonthChange?.(next);
    if (say) {
      const { name, income, outcome } = months[next];
      setAnnouncement(
        `${name}: ${series.income} ${money.format(income)}, ${series.outcome} ${money.format(outcome)}.`,
      );
    }
  };

  /** The month under a point of the screen (the chart may be drawn scaled). */
  const monthAt = (clientX: number) => {
    const element = band.current;
    if (!element) return active;
    const box = element.getBoundingClientRect();
    return Math.floor(((clientX - box.left) / box.width) * months.length);
  };

  // The bars grow in the first time the chart is on screen. Until then, a stage that starts off
  // screen keeps them hidden, so that they do not show full and then drop to nothing.
  useEffect(() => {
    const stage = root.current;
    const area = plot.current;
    if (!stage || !area || !growIn || reducedMotion()) return;
    const running: Animation[] = [];
    let first = true;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) {
          if (first) stage.dataset.waiting = '';
          first = false;
          return;
        }
        observer.disconnect();
        delete stage.dataset.waiting;
        const easing = 'cubic-bezier(0.22, 1, 0.36, 1)';
        const hidden = 'inset(100% 0 0 0 round 6px)';
        area.querySelectorAll<HTMLElement>('[data-month]').forEach((bar) => {
          const delay = Number(bar.dataset.month) * STAGGER;
          const grow = { duration: GROW, delay, easing, fill: 'backwards' as const };
          // A muted bar is its own height; a glow is clipped to its value (as its stylesheet does).
          const rest = 1 - Number(bar.style.getPropertyValue('--v') || 0);
          const shown = bar.hasAttribute('data-glow')
            ? `inset(calc(${(rest * 100).toFixed(3)}% + ${(rest * 4).toFixed(3)}px) 0 0 0 round 6px)`
            : 'inset(0 0 0 0 round 6px)';
          running.push(bar.animate([{ clipPath: hidden }, { clipPath: shown }], grow));
        });
        const tip = area.querySelector<HTMLElement>('[data-tip]');
        if (tip) {
          running.push(
            tip.animate([{ opacity: 0 }, { opacity: 1 }], {
              duration: 320,
              delay: GROW * 0.55 + months.length * STAGGER * 0.5,
              fill: 'backwards',
            }),
          );
        }
      },
      { threshold: 0.3 },
    );
    observer.observe(stage);
    return () => {
      observer.disconnect();
      running.forEach((animation) => animation.cancel());
      delete stage.dataset.waiting;
    };
  }, [root, months.length, growIn]);

  // Once a month is lit and the glide has settled, a sheen runs up its two bars.
  useEffect(() => {
    const area = plot.current;
    if (!area || reducedMotion()) return;
    const running: Animation[] = [];
    const timer = window.setTimeout(() => {
      area.querySelectorAll<HTMLElement>('[data-sheen]').forEach((sheen, index) => {
        const value = share(index === 0 ? month.income : month.outcome);
        // From under the bar to past its top, in the sheen's own height (30% of the column).
        const travel = ((value + 0.3) / 0.3) * 100;
        running.push(
          sheen.animate(
            [
              { transform: 'translateY(0)', opacity: 0 },
              { opacity: 1, offset: 0.2 },
              { opacity: 1, offset: 0.7 },
              { transform: `translateY(-${travel.toFixed(1)}%)`, opacity: 0 },
            ],
            { duration: 1300, delay: index * 90, easing: 'cubic-bezier(0.3, 0, 0.3, 1)' },
          ),
        );
      });
    }, SHEEN_DELAY);
    return () => {
      window.clearTimeout(timer);
      running.forEach((animation) => animation.cancel());
    };
    // The sheen runs for each new month; the month's figures come with it.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active]);

  // The demo: the pointer comes onto January, sweeps slowly to July, comes back to March, rests,
  // and leaves the chart for a while.
  const demo = autoplay && playing && !keyboard;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    /** A point over month `index`, `height` of the way up the plot, on the screen. */
    const over = (index: number, height: number): Point => {
      const box = band.current?.getBoundingClientRect();
      if (!box) return { x: 0, y: 0 };
      return {
        x: box.left + ((index + 0.5) / months.length) * box.width,
        y: box.bottom - height * box.height,
      };
    };
    const stop = runDemo(stage, arrow, async ({ wait, moveTo, sweep }) => {
      /** Sweeps from over month `from` to over month `to`, lighting each month it passes. */
      const across = (from: number, to: number, height: number, ms: number) =>
        sweep(over(to, height), ms, (share) => {
          // The pointer runs from the middle of one month to the middle of the other.
          light(Math.floor(from + 0.5 + (to - from) * share), 'demo');
        });
      await wait(1400);
      for (;;) {
        await moveTo(over(0, 0.34));
        light(0, 'demo');
        await wait(700);
        await across(0, last, 0.46, 520 * last);
        await wait(1100);
        const back = Math.min(2, last);
        await across(last, back, 0.4, 600 * Math.max(1, last - back));
        await wait(2600);
        const box = stage.getBoundingClientRect();
        await moveTo({ x: box.left + box.width * 0.86, y: box.top + box.height * 0.9 });
        await wait(2200);
      }
    });
    stopDemo.current = stop;
    return stop;
    // The demo reads the months through `over` and `light` as it plays.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  /** The reader takes over: the demo stops at once and waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    take();
  };

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    // A touch picks while it is down; a mouse or a pen as it hovers.
    if (event.pointerType === 'touch' && !event.buttons) return;
    readerActs();
    const index = monthAt(event.clientX);
    if (index !== current.current && index >= 0 && index <= last) light(index, 'reader');
  };

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    const index = monthAt(event.clientX);
    if (index >= 0 && index <= last) light(index, 'reader', true);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const from = current.current;
    const moves: Partial<Record<string, number>> = {
      ArrowRight: from + 1,
      ArrowDown: from + 1,
      ArrowLeft: from - 1,
      ArrowUp: from - 1,
      Home: 0,
      End: last,
    };
    const next = moves[event.key];
    if (next === undefined) return;
    event.preventDefault();
    light(next, 'reader', true);
  };

  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };

  const income = share(month.income);
  const outcome = share(month.outcome);
  // Near the right edge the tooltip sits on the other side of the pair.
  const flip = (active + 0.5) / months.length > 0.7 ? 1 : 0;
  const lit = {
    '--n': months.length,
    '--i': active,
    '--top': Math.max(income, outcome),
    '--flip': flip,
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <section className={styles.card} aria-label={title}>
        <header className={styles.head}>
          <div>
            <h3 className={styles.title} data-part="title">
              {title}
            </h3>
            <p className={styles.subtitle}>{subtitle}</p>
          </div>
          <button
            type="button"
            className={styles.menu}
            aria-label="Chart options"
            onClick={onMenu}
            data-part="menu"
          >
            <span className={styles.dots} aria-hidden="true" />
          </button>
        </header>

        <div className={styles.ticks} aria-hidden="true" data-part="ticks">
          {Array.from({ length: STEPS + 1 }, (_, step) => (
            <span key={step} style={{ '--t': 1 - step / STEPS } as CSSProperties}>
              {tick((max * step) / STEPS)}
            </span>
          ))}
        </div>

        <div
          className={styles.chart}
          role="group"
          aria-roledescription="chart"
          aria-label={`${title}, ${months[0].name} to ${months[last].name}`}
          aria-describedby={hintId}
          tabIndex={0}
          onPointerMove={onPointerMove}
          onPointerDown={onPointerDown}
          onKeyDown={onKeyDown}
        >
          <div ref={plot} className={styles.plot} aria-hidden="true">
            {Array.from({ length: STEPS + 1 }, (_, step) => (
              <span
                key={step}
                className={styles.gridline}
                style={{ '--t': step / STEPS } as CSSProperties}
              />
            ))}
            <span className={styles.axis} />
            <div ref={band} className={styles.band}>
              {months.map((entry, index) => (
                <span key={entry.name} className={styles.column}>
                  <span
                    className={styles.bar}
                    data-part="bar"
                    data-month={index}
                    style={{ '--v': share(entry.income) } as CSSProperties}
                  />
                  <span
                    className={`${styles.bar} ${styles.out}`}
                    data-part="bar"
                    data-month={index}
                    style={{ '--v': share(entry.outcome) } as CSSProperties}
                  />
                </span>
              ))}
            </div>
            <div className={styles.slot} style={lit} data-part="lit">
              {/* Each bar in a halo: the light it casts on a light card. */}
              <span className={`${styles.halo} ${styles.incomes}`}>
                <span
                  className={`${styles.glow} ${styles.incomes}`}
                  data-month={active}
                  data-glow=""
                  style={{ '--v': income } as CSSProperties}
                >
                  <span className={styles.sheen} data-sheen="" />
                </span>
              </span>
              <span className={`${styles.halo} ${styles.outcomes}`}>
                <span
                  className={`${styles.glow} ${styles.outcomes}`}
                  data-month={active}
                  data-glow=""
                  style={{ '--v': outcome } as CSSProperties}
                >
                  <span className={styles.sheen} data-sheen="" />
                </span>
              </span>
              <span className={styles.lift}>
                <span className={styles.anchor}>
                  <span className={styles.flipper}>
                    <span className={styles.tip} data-tip="" data-part="tooltip">
                      <span className={styles.entry}>
                        <span className={styles.figureRow}>
                          <span className={`${styles.mark} ${styles.incomes}`} />
                          <RollingFigure text={money.format(month.income)} />
                        </span>
                        <span className={styles.date}>{month.name}</span>
                      </span>
                      <span className={styles.divider} />
                      <span className={styles.entry}>
                        <span className={styles.figureRow}>
                          <span className={`${styles.mark} ${styles.outcomes}`} />
                          <RollingFigure text={money.format(month.outcome)} />
                        </span>
                        <span className={styles.date}>{month.name}</span>
                      </span>
                    </span>
                  </span>
                </span>
              </span>
            </div>
          </div>
          <div className={styles.months} aria-hidden="true" data-part="months">
            {months.map((entry) => (
              <span key={entry.name}>{entry.short}</span>
            ))}
          </div>
        </div>

        <ul className={styles.legend} data-part="legend">
          <li>
            <span className={`${styles.swatch} ${styles.incomes}`} aria-hidden="true" />
            {series.income}
          </li>
          <li>
            <span className={`${styles.swatch} ${styles.outcomes}`} aria-hidden="true" />
            {series.outcome}
          </li>
        </ul>
        <p id={hintId} className={styles.hidden}>
          Use the left and right arrow keys to read each month.
        </p>
        <p className={styles.hidden} aria-live="polite">
          {announcement}
        </p>
      </section>
      <div className={styles.pointerLayer}>
        <DemoPointer ref={pointer} />
      </div>
    </div>
  );
}
```

### glow-bars/glow-bars.module.css

```css
/* Glow bars (glow-bars.tsx): a white card on a light square grid, or at night a dark one. The card
   is laid out at fixed places, in px, for a width of 640; narrower, the plot's columns share what
   is left.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). The stage's grid is the same in both. The lit bars' gradients, rims
   and light are pairs in their own rules, further down.

   Memory. The stage is the one layout boundary, of a fixed height. What moves while the pointer
   sweeps stays on small layers of a fixed size: the lit column (its glow repaints inside it as
   the bars stretch), the tooltip and the reels of its digits. Nothing that moves changes size. */

.stage {
  /* The stage, and the card on it. */
  --surface: #edecef;
  --surface-2: #fff;
  /* The title; the subtitle; the axis' labels; the months; the legend. */
  --ink: #17181c;
  --ink-muted: #686b73;
  --ink-faint: #74767d;
  --month-ink: #71737a;
  --legend-ink: #6b6d74;
  /* The gridlines and the axis. */
  --line: rgb(0 0 0 / 5%);
  --axis: rgb(0 0 0 / 8%);
  /* The card's edge and its three shadows, from the nearest to the widest. */
  --edge: rgb(0 0 0 / 7%);
  --shadow-near: rgb(0 0 0 / 6%);
  --shadow-mid: rgb(0 0 0 / 6%);
  --shadow: rgb(0 0 0 / 14%);
  /* The ⋮ button: its face, under the pointer, its rim and its shade; its dots. */
  --button: #fff;
  --button-hover: #f3f3f6;
  --button-edge: rgb(0 0 0 / 10%);
  --button-shadow: rgb(0 0 0 / 6%);
  --dots: #6f6e74;
  --focus: #5b5bd6;
  /* A month's two muted bars, from top to bottom. */
  --bar-top: #e9e9ee;
  --bar-bottom: #f3f3f6;
  /* The tooltip: its face from top to bottom, its hairline (none at night) and its two shadows;
     its figures and dates. */
  --tip-top: #fff;
  --tip-bottom: #fff;
  --tip-edge: rgb(0 0 0 / 7%);
  --tip-shadow: rgb(0 0 0 / 12%);
  --tip-shadow-near: rgb(0 0 0 / 8%);
  --figure-ink: #17181c;
  --date-ink: #6e7078;
  --glide: 0.42s;
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  display: grid;
  place-items: center;
  height: 540px;
  contain: strict;
  container-type: inline-size;
  border-radius: 18px;
  background-color: var(--surface);
  background-image:
    linear-gradient(to right, rgb(0 0 0 / 4.5%) 1px, transparent 1px),
    linear-gradient(to bottom, rgb(0 0 0 / 4.5%) 1px, transparent 1px);
  background-position: calc(50% + 36px) calc(50% + 36px);
  background-size: 72px 72px;
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 6%);
  font:
    400 14px/1.3 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface-2: light-dark(#fff, #111319);
    --ink: light-dark(#17181c, #ededf0);
    --ink-muted: light-dark(#686b73, #a9abb0);
    --ink-faint: light-dark(#74767d, #898b90);
    --month-ink: light-dark(#71737a, #7a7c81);
    --legend-ink: light-dark(#6b6d74, #9a9ca2);
    --line: light-dark(rgb(0 0 0 / 5%), rgb(255 255 255 / 2%));
    --axis: light-dark(rgb(0 0 0 / 8%), rgb(255 255 255 / 3%));
    --edge: light-dark(rgb(0 0 0 / 7%), rgb(0 0 0 / 12%));
    --shadow-near: light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 30%));
    --shadow-mid: light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 30%));
    --shadow: light-dark(rgb(0 0 0 / 14%), rgb(0 0 0 / 50%));
    --button: light-dark(#fff, #1f2126);
    --button-hover: light-dark(#f3f3f6, #26282e);
    --button-edge: light-dark(rgb(0 0 0 / 10%), rgb(255 255 255 / 4%));
    --button-shadow: light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 40%));
    --dots: light-dark(#6f6e74, #8a898d);
    --focus: light-dark(#5b5bd6, #8b8cff);
    --bar-top: light-dark(#e9e9ee, #202228);
    --bar-bottom: light-dark(#f3f3f6, #16181c);
    --tip-top: light-dark(#fff, #1d1f23);
    --tip-bottom: light-dark(#fff, #23242d);
    --tip-edge: light-dark(rgb(0 0 0 / 7%), transparent);
    --tip-shadow: light-dark(rgb(0 0 0 / 12%), rgb(0 0 0 / 60%));
    --tip-shadow-near: light-dark(rgb(0 0 0 / 8%), rgb(0 0 0 / 35%));
    --figure-ink: light-dark(#17181c, #fff);
    --date-ink: light-dark(#6e7078, #b4b6bd);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}

.card {
  position: relative;
  width: min(640px, calc(100% - 32px));
  height: 402px;
  border-radius: 13px;
  background: var(--surface-2);
  box-shadow:
    inset 0 0 0 1px rgb(255 255 255 / 3%),
    0 0 0 1px var(--edge),
    0 1px 3px var(--shadow-near),
    0 10px 20px -6px var(--shadow-mid),
    0 34px 52px -22px var(--shadow);
  color: var(--ink);
}

/* The title, its line and the ⋮ button. */
.head {
  position: absolute;
  top: 20px;
  right: 18px;
  left: 18px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.title {
  margin: 0;
  font-size: 17.4px;
  font-weight: 500;
  line-height: 21px;
  letter-spacing: 0;
  color: var(--ink);
}
.subtitle {
  margin: 5px 0 0;
  font-size: 14px;
  line-height: 18px;
  color: var(--ink-muted);
}
.menu {
  display: grid;
  flex: none;
  place-items: center;
  width: 28px;
  height: 28px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: var(--button);
  box-shadow:
    inset 0 0 0 1px var(--button-edge),
    0 1px 2px var(--button-shadow);
  cursor: pointer;
  transition: background-color 0.2s ease;
}
.dots {
  width: 2.2px;
  height: 2.2px;
  border-radius: 50%;
  background: var(--dots);
  box-shadow:
    0 -4.3px 0 var(--dots),
    0 4.3px 0 var(--dots);
}
@media (hover: hover) {
  .menu:hover {
    background: var(--button-hover);
  }
}
.menu:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The y axis' labels, right-aligned, each on its gridline. */
.ticks {
  position: absolute;
  top: 85px;
  left: 0;
  width: 45.5px;
  height: 245px;
  font-size: 11.3px;
  line-height: 14px;
  color: var(--ink-faint);
}
.ticks span {
  position: absolute;
  top: calc(var(--t) * 100%);
  right: 0;
  transform: translateY(-50%);
  white-space: nowrap;
}

/* The chart: the plot, from the $6k line to the $0 line, and the months under it. It takes the
   pointer and the keyboard. */
.chart {
  position: absolute;
  top: 85px;
  right: 0;
  left: 57.7px;
  height: 274px;
  outline: none;
  cursor: default;
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
}
.chart::after {
  position: absolute;
  inset: -12px 8px -6px -10px;
  border-radius: 8px;
  content: '';
  pointer-events: none;
}
.chart:focus-visible::after {
  outline: 2px solid var(--focus);
}
.plot {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  height: 245px;
}
.gridline {
  position: absolute;
  top: calc(var(--t) * 100% - 0.5px);
  right: 0;
  left: 0;
  height: 1px;
  background: var(--line);
}
.axis {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 1px;
  background: var(--axis);
}

/* The months' columns, and in each one its two muted bars. A bar stops 4px above the $0 line;
   its top is on its value. */
.band {
  position: absolute;
  top: 0;
  right: 27.8px;
  bottom: 0;
  left: 9px;
  display: flex;
}
.column {
  position: relative;
  flex: 1;
}
.bar {
  position: absolute;
  bottom: 4px;
  left: 15.85%;
  width: 30%;
  height: max(0px, calc(var(--v) * 100% - 4px));
  border-radius: 6px;
  background: linear-gradient(var(--bar-top), var(--bar-bottom));
}
.out {
  right: 15.85%;
  left: auto;
}

/* The lit month: one column wide, slid to its place. Its bars are drawn full height and clipped
   to their values, so that they stretch from month to month without changing size. */
.slot {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 9px;
  width: calc((100% - 9px - 27.8px) / var(--n));
  transform: translateX(calc(var(--i) * 100%));
  transition: transform var(--glide) var(--ease);
  will-change: transform;
}
/* The light the lit bars cast on the white card: a soft shadow in each bar's colour, drawn by a
   wrapper the size of the column, so that it follows the bar as it is clipped and grows in. At
   night it is transparent: the bars shine on their own. */
.halo {
  position: absolute;
  inset: 0;
  filter: drop-shadow(0 6px 14px var(--halo));
}
.halo.incomes {
  --halo: rgb(200 110 245 / 45%);
}
@supports (color: light-dark(#fff, #000)) {
  .halo.incomes {
    --halo: light-dark(rgb(200 110 245 / 45%), transparent);
  }
}
.halo.outcomes {
  --halo: rgb(80 150 245 / 45%);
}
@supports (color: light-dark(#fff, #000)) {
  .halo.outcomes {
    --halo: light-dark(rgb(80 150 245 / 45%), transparent);
  }
}
.glow {
  /* The visible bar's height, in the glow's own height (which ends 4px above the plot's). */
  --size: calc(var(--v) * (100% + 4px) - 4px);
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 -0.46'/%3E%3C/filter%3E%3Crect width='48' height='48' filter='url(%23g)'/%3E%3C/svg%3E");
  position: absolute;
  top: 0;
  bottom: 4px;
  left: 15.85%;
  width: 30%;
  clip-path: inset(calc((1 - var(--v)) * (100% + 4px)) 0 0 0 round 6px);
  background-repeat: no-repeat, repeat, no-repeat, no-repeat;
  background-position:
    0 100%,
    0 0,
    0 100%,
    0 100%;
  background-size:
    100% var(--size),
    48px 48px,
    100% var(--size),
    100% var(--size);
  transition:
    clip-path var(--glide) var(--ease),
    background-size var(--glide) var(--ease);
}
.glow.outcomes {
  right: 15.85%;
  left: auto;
}
/* Layered: the frosted rim along the top, a faint grain, the light in the middle, and the bar's
   run from top to bottom. The rim along the sides and the bottom is an inset shadow. On white,
   each colour is a shade deeper and the rims are richer, so that the bars hold their edges. */
.glow.incomes {
  background-image:
    linear-gradient(rgb(214 190 240 / 30%), transparent 3px), var(--grain),
    radial-gradient(46% 34% at 54% 42%, rgb(250 150 255 / 45%), transparent),
    linear-gradient(
      #6a3fae 0%,
      #ad5ae6 12%,
      #d877f8 24%,
      #ea84fc 40%,
      #e27cfc 62%,
      #cf72fa 74%,
      #a35bec 85%,
      #8a50e2 93%,
      #946ade 100%
    );
  box-shadow:
    inset 2px 0 2.5px rgb(140 92 210 / 70%),
    inset -1.5px 0 2px rgb(160 105 225 / 55%),
    inset 0 -2px 2.5px rgb(150 110 220 / 60%);
}
@supports (color: light-dark(#fff, #000)) {
  /* Layered: the frosted rim along the top, a faint grain, the light in the middle, and the bar's
   run from top to bottom. The rim along the sides and the bottom is an inset shadow. On white,
   each colour is a shade deeper and the rims are richer, so that the bars hold their edges. */
  .glow.incomes {
    background-image:
      linear-gradient(light-dark(rgb(214 190 240 / 30%), rgb(214 190 240 / 45%)), transparent 3px),
      var(--grain),
      radial-gradient(
        46% 34% at 54% 42%,
        light-dark(rgb(250 150 255 / 45%), rgb(250 150 255 / 50%)),
        transparent
      ),
      linear-gradient(
        #6a3fae 0%,
        light-dark(#ad5ae6, #b464e8) 12%,
        light-dark(#d877f8, #df84fb) 24%,
        light-dark(#ea84fc, #f292ff) 40%,
        light-dark(#e27cfc, #ea8aff) 62%,
        light-dark(#cf72fa, #d880fe) 74%,
        light-dark(#a35bec, #ab66f0) 85%,
        light-dark(#8a50e2, #9159e6) 93%,
        light-dark(#946ade, #a079e6) 100%
      );
    box-shadow:
      inset 2px 0 2.5px light-dark(rgb(140 92 210 / 70%), rgb(178 150 212 / 75%)),
      inset -1.5px 0 2px light-dark(rgb(160 105 225 / 55%), rgb(196 160 236 / 55%)),
      inset 0 -2px 2.5px light-dark(rgb(150 110 220 / 60%), rgb(196 170 236 / 55%));
  }
}
.glow.outcomes {
  background-image:
    linear-gradient(rgb(150 160 210 / 35%), transparent 3px), var(--grain),
    radial-gradient(56% 30% at 50% 54%, rgb(150 240 250 / 45%), transparent),
    linear-gradient(
      #3a46a2 0%,
      #4b5ee2 13%,
      #5f8ffa 27%,
      #74c0f7 40%,
      #7fdbf4 52%,
      #78c9fa 63%,
      #66abfb 75%,
      #538de8 87%,
      #4f86d8 95%,
      #6390dc 100%
    );
  box-shadow:
    inset 2px 0 2.5px rgb(82 124 204 / 70%),
    inset -1.5px 0 2px rgb(92 136 214 / 55%),
    inset 0 -2px 2.5px rgb(104 146 220 / 65%);
}
@supports (color: light-dark(#fff, #000)) {
  .glow.outcomes {
    background-image:
      linear-gradient(light-dark(rgb(150 160 210 / 35%), rgb(150 160 210 / 50%)), transparent 3px),
      var(--grain),
      radial-gradient(
        56% 30% at 50% 54%,
        light-dark(rgb(150 240 250 / 45%), rgb(170 250 250 / 60%)),
        transparent
      ),
      linear-gradient(
        #3a46a2 0%,
        #4b5ee2 13%,
        light-dark(#5f8ffa, #6898fd) 27%,
        light-dark(#74c0f7, #8bcdfa) 40%,
        light-dark(#7fdbf4, #9de9f8) 52%,
        light-dark(#78c9fa, #90d8fd) 63%,
        light-dark(#66abfb, #74b8fe) 75%,
        light-dark(#538de8, #5b97ec) 87%,
        light-dark(#4f86d8, #5890de) 95%,
        light-dark(#6390dc, #6d9be2) 100%
      );
    box-shadow:
      inset 2px 0 2.5px light-dark(rgb(82 124 204 / 70%), rgb(150 174 214 / 75%)),
      inset -1.5px 0 2px light-dark(rgb(92 136 214 / 55%), rgb(150 174 220 / 55%)),
      inset 0 -2px 2.5px light-dark(rgb(104 146 220 / 65%), rgb(170 196 232 / 70%));
  }
}
/* The sheen that runs up a bar once it settles, from just under it. */
.sheen {
  position: absolute;
  top: 100%;
  right: 0;
  left: 0;
  height: 30%;
  background: linear-gradient(transparent, rgb(255 255 255 / 36%) 50%, transparent);
  opacity: 0;
  pointer-events: none;
}

/* The tooltip. The lift puts its top on the taller bar's top; the anchor is the pair of bars;
   the flipper moves to the pair's right end near the edge of the chart, and the tooltip hangs
   from it, 11px out from the pair and 13px down over the bar. */
.lift {
  position: absolute;
  inset: 0;
  transform: translateY(calc((1 - var(--top)) * 100%));
  transition: transform var(--glide) var(--ease);
  pointer-events: none;
  will-change: transform;
}
.anchor {
  position: absolute;
  top: 0;
  left: 15.85%;
  width: 68.3%;
  height: 0;
}
.flipper {
  position: relative;
  display: block;
  width: 100%;
  height: 0;
  transform: translateX(calc(var(--flip) * 100%));
  transition: transform var(--glide) var(--ease);
}
.tip {
  --tip-w: 197.8px;
  position: absolute;
  top: 0;
  left: 0;
  display: grid;
  grid-template-columns: 99.4px 1px 1fr;
  width: var(--tip-w);
  height: 47.6px;
  border-radius: 5.6px;
  background: linear-gradient(var(--tip-top), var(--tip-bottom));
  box-shadow:
    0 0 0 1px var(--tip-edge),
    0 8px 20px var(--tip-shadow),
    0 1px 3px var(--tip-shadow-near);
  transform: translate(calc(var(--flip) * (22px - 100%) - 11px), calc(13px - 100%));
  transition: transform var(--glide) var(--ease);
  will-change: transform;
}
.entry {
  display: flex;
  flex-direction: column;
  padding: 8.1px 0 0 9.3px;
}
.figureRow {
  display: flex;
  align-items: flex-start;
  font-size: 14.2px;
  font-weight: 500;
  line-height: 16px;
  color: var(--figure-ink);
}
.mark {
  flex: none;
  width: 2.8px;
  height: 11.7px;
  margin: 3px 7.5px 0 0;
  border-radius: 2px;
}
.mark.incomes {
  background: linear-gradient(#a974ee, #8a5bd6);
}
@supports (color: light-dark(#fff, #000)) {
  .mark.incomes {
    background: linear-gradient(light-dark(#a974ee, #cfa4ff), light-dark(#8a5bd6, #a684dc));
  }
}
.mark.outcomes {
  background: linear-gradient(#6a8be8, #5a78cc);
}
@supports (color: light-dark(#fff, #000)) {
  .mark.outcomes {
    background: linear-gradient(light-dark(#6a8be8, #b3c6fa), light-dark(#5a78cc, #8ea4d4));
  }
}
.date {
  margin-top: 6.6px;
  font-size: 8.3px;
  font-weight: 500;
  line-height: 10px;
  white-space: nowrap;
  color: var(--date-ink);
}
.divider {
  background: linear-gradient(rgb(0 0 0 / 2%), rgb(0 0 0 / 9%) 50%, rgb(0 0 0 / 3%));
}
@supports (color: light-dark(#fff, #000)) {
  .divider {
    background: linear-gradient(
      light-dark(rgb(0 0 0 / 2%), rgb(255 255 255 / 2%)),
      light-dark(rgb(0 0 0 / 9%), rgb(255 255 255 / 9%)) 50%,
      light-dark(rgb(0 0 0 / 3%), rgb(255 255 255 / 3%))
    );
  }
}

/* A rolling figure: each digit a window one line tall over a reel of 0 to 9. */
.figure {
  display: flex;
  font-variant-numeric: tabular-nums;
}
.glyph,
.digit {
  display: block;
  height: 16px;
}
.digit {
  overflow: hidden;
}
.reel {
  display: block;
  transform: translateY(calc(var(--d) * -10%));
  transition: transform 0.55s var(--ease);
  will-change: transform;
}
.reel span {
  display: block;
  height: 16px;
}

/* The months, one under each column. */
.months {
  position: absolute;
  top: 258.9px;
  right: 27.8px;
  left: 9px;
  display: flex;
  font-size: 11.8px;
  line-height: 14px;
  color: var(--month-ink);
}
.months span {
  flex: 1;
  text-align: center;
}

.legend {
  position: absolute;
  top: 372.6px;
  right: 0;
  left: 0;
  display: flex;
  justify-content: center;
  gap: 27.7px;
  margin: 0;
  padding: 0;
  font-size: 9.3px;
  font-weight: 500;
  line-height: 12px;
  color: var(--legend-ink);
  list-style: none;
}
.legend li {
  display: flex;
  align-items: center;
  gap: 4.7px;
}
.swatch {
  width: 9.3px;
  height: 9.3px;
  border-radius: 2.5px;
}
.swatch.incomes {
  background: radial-gradient(circle at 50% 55%, #d867f3 30%, #c052ed 75%, #c46ff0);
}
@supports (color: light-dark(#fff, #000)) {
  .swatch.incomes {
    background: radial-gradient(
      circle at 50% 55%,
      light-dark(#d867f3, #f38dff) 30%,
      light-dark(#c052ed, #e07cff) 75%,
      light-dark(#c46ff0, #eaa8ff)
    );
  }
}
.swatch.outcomes {
  background: radial-gradient(circle at 45% 60%, #3fa9ee 25%, #2a86de 75%, #3a9ae8);
}
@supports (color: light-dark(#fff, #000)) {
  .swatch.outcomes {
    background: radial-gradient(
      circle at 45% 60%,
      light-dark(#3fa9ee, #92dcff) 25%,
      light-dark(#2a86de, #63b6fb) 75%,
      light-dark(#3a9ae8, #9fd0fb)
    );
  }
}

/* Until the chart first comes into view, its bars and tooltip wait, hidden, to grow in. */
.stage[data-waiting] .band,
.stage[data-waiting] .slot {
  opacity: 0;
}

/* The demo's pointer moves over the whole stage, in a box of its own. */
.pointerLayer {
  position: absolute;
  z-index: 10;
  inset: 0;
  contain: strict;
  pointer-events: none;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  /* On a layer of its own, so that a new announcement repaints nothing else. */
  will-change: opacity;
}

/* Narrow: smaller type, and a tooltip with its two figures stacked under one date. */
@container (max-width: 520px) {
  .head {
    right: 14px;
    left: 14px;
  }
  .title {
    font-size: 15px;
    line-height: 19px;
  }
  .subtitle {
    margin-top: 4px;
    font-size: 11px;
    line-height: 15px;
  }
  .tip {
    --tip-w: 112px;
    grid-template-columns: 1fr;
    height: auto;
    padding-bottom: 7px;
  }
  .entry {
    padding-top: 7px;
  }
  .entry + .divider,
  .entry:first-child .date {
    display: none;
  }
  .entry + .divider + .entry {
    padding-top: 3px;
  }
}

/* Narrower still: the months and the axis in smaller type. */
@container (max-width: 329px) {
  .months {
    font-size: 9.6px;
  }
  .ticks {
    font-size: 10px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .slot,
  .glow,
  .lift,
  .flipper,
  .tip,
  .reel,
  .menu {
    transition: none;
  }
}
```

### glow-bars/months.ts

```tsx
/** One month of the chart: what came in and what went out. */
export type CashMonth = {
  /** The short name under the axis: "Mar". */
  short: string;
  /** The month in full, in the tooltip and for screen readers: "March 2025". */
  name: string;
  /** Money in, in dollars. */
  income: number;
  /** Money out, in dollars. */
  outcome: number;
};

/** Sample months, January to July. */
export const MONTHS: CashMonth[] = [
  { short: 'Jan', name: 'January 2025', income: 2698.4, outcome: 2184.75 },
  { short: 'Feb', name: 'February 2025', income: 5072.1, outcome: 3106.3 },
  { short: 'Mar', name: 'March 2025', income: 4234.2, outcome: 2630 },
  { short: 'Apr', name: 'April 2025', income: 2061.5, outcome: 1214.8 },
  { short: 'May', name: 'May 2025', income: 2468.9, outcome: 1532.4 },
  { short: 'Jun', name: 'June 2025', income: 3248.6, outcome: 1947.2 },
  { short: 'Jul', name: 'July 2025', income: 4318.7, outcome: 2652.9 },
];
```

### glow-bars/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide
 * the pointer to a point, or sweep it slowly along a line while the chart follows it. Stopping the
 * demo (the reader takes over, or it scrolls away) ends the step in progress at once: its promise
 * rejects, which ends the script right there, and the pointer fades out.
 *
 * The pointer moves by animations the compositor plays (a sweep too: the chart reads that
 * animation's clock every frame, as a share of the way, and never the page's geometry), and it is
 * an HTML box around a drawing that never changes, so moving it never makes the browser lay
 * anything out.
 */

export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to a point of the screen, in client px. */
  moveTo: (point: Point) => Promise<void>;
  /**
   * Sweeps the pointer to a point of the screen (client px) over `ms`, eased; `step` gets the
   * share of the way done every frame.
   */
  sweep: (to: Point, ms: number, step: (share: number) => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, in its 20 × 20 box: the point the pointer acts on. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  let frame = 0;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.82, y: stage.offsetHeight * 0.92 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  /** A point of the screen in the stage's own px (the stage may sit in a scaled container). */
  const toStage = (point: Point): Point => {
    const box = stage.getBoundingClientRect();
    const scale = box.width / stage.offsetWidth || 1;
    return { x: (point.x - box.left) / scale, y: (point.y - box.top) / scale };
  };

  const moveTo: Demo['moveTo'] = (point) => {
    const from = at;
    const to = toStage(point);
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const sweep: Demo['sweep'] = (point, ms, onStep) => {
    const from = at;
    const to = toStage(point);
    at = to;
    // The path, eased, as an animation; the chart reads the same clock every frame.
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const share = easeInOut(i / 16);
      keyframes.push({
        transform: place({
          x: from.x + (to.x - from.x) * share,
          y: from.y + (to.y - from.y) * share,
        }),
      });
    }
    flight?.cancel();
    pointer.style.transform = place(to);
    const motion = pointer.animate(keyframes, { duration: ms, easing: 'linear' });
    flight = motion;
    return step(
      new Promise<void>((resolve) => {
        const tick = () => {
          const time = motion.currentTime;
          // Clamped at both ends; a finished animation is the end of the way.
          const t =
            motion.playState === 'finished' ? 1 : clamp(typeof time === 'number' ? time / ms : 0);
          onStep(easeInOut(t));
          if (t < 1) frame = requestAnimationFrame(tick);
          else {
            frame = 0;
            resolve();
          }
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, sweep }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    cancelAnimationFrame(frame);
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.25))',
  willChange: 'transform',
};
const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/** The arrow the demo moves around. It is decorative, and invisible while no demo plays. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <svg viewBox="0 0 20 20" style={DRAWING}>
        <path
          d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
          fill="#1c1c1e"
          stroke="#fff"
          strokeWidth="1.3"
          strokeLinejoin="round"
        />
      </svg>
    </span>
  );
}
```

### glow-bars/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```