# Isometric bars

> A with-or-without comparison drawn as isometric prisms that rise on entry, count their pills up and drop a floating cap.

Two series side by side, group by group: a lavender-to-magenta prism for the value with a product and a plain grey one for the value without it, each under a pill with its figure. On entering the screen the prisms rise out of their plinths one after another while the pills count up, and an open value such as 150+ drops its separated cap into place. Hover or focus a prism to dim the rest and read both values in a tooltip.

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

Category: Data.

## Anatomy

1. **Card**: A figure on a pale stage, 622 px wide at most; narrower, its padding and the gaps between groups shrink.
2. **Legend**: The figure’s caption: a gradient dot for “With [mark] product” and a grey one for the other series.
3. **“With” prism**: Two sides and a top in a lavender-to-magenta gradient, darker at the foot, with the mark faded on its left face. Its sides are its value over the group’s max, of 197 px.
4. **Floating cap**: An open value’s top 28.5 px, lifted 8.5 px off the body: the figure is a floor, not a count.
5. **Pill**: The figure over each prism: filled black for “with”, outlined white for “without”. It counts up while the prism rises.
6. **“Without” prism**: The same shape in plain greys, on its own plinth to the right of the “with” one.
7. **Bracket**: A hairline that spans the group’s two prisms, with a tick at each end.
8. **Label**: The group’s name in capitals after a small grey disc with a glyph (chart, target, clock, or any node).

## When to use

- A before-and-after or with-and-without claim on a landing page, where two figures per metric carry the story.
- Two to four headline metrics, each compared on its own scale, so a count and a percentage can sit side by side.
- A figure that is a floor rather than a count (“150+”): the floating cap says “at least” without a footnote.
- A section the reader scrolls into, where a single, short entrance (the prisms rising, the pills counting) earns attention.

## When not to use

- More than two series, or more than four or five groups: the prisms get thin. Use a grouped bar chart.
- Values the reader compares across groups on one axis: each group here has its own max. Use a flat bar chart with a shared scale.
- A trend over time. Use a line or area chart.
- Exact reading of many numbers, or values to sort and export. Use a table.
- A dense dashboard tile, under about 300 px wide: the depth and pills crowd. Use a stat with a delta, or a sparkline.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/iso-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
import { IsoBars } from './iso-bars';

export function Impact() {
  return (
    <IsoBars
      product="Gridwell"
      groups={[
        { label: 'Data points', icon: 'chart', with: 150, without: 30, max: 171, open: true },
        { label: 'Hit rate', icon: 'target', with: 98, without: 40, unit: '%' },
        { label: 'Setup time', icon: 'clock', with: 4, without: 26, unit: ' min' },
      ]}
      autoplay={false}
    />
  );
}
```

## States

- **At rest**: Drawn complete: `animate={false}`, or once the entrance has played. The first value is open, so its cap floats.
- **“With” prism lit**: Hover or focus: the other prisms fade to 35% and a tooltip gives both values, this one first. The first prism’s tooltip leans right, into the card.
- **“Without” prism lit**: The last prism’s tooltip leans left, so it stays on the card; the lit row is in full ink, the other muted.
- **Closed values**: No group is open: every prism is one piece, and every pill an exact figure.
- **Three groups**: Each group keeps its own scale: a setup time of 4 min next to 26 min, beside a hit rate.
- **One group**: A single pair stays in the middle of the card, at the same column width.
- **No mark, no icons**: `mark={null}` drops the diamond from the legend and the faces; `icon: null` drops the discs.
- **Custom format**: `format(value, series)` writes the figures, while they count too: here as revenue in thousands.
- **Light**: Every colour is a light-dark() token: `colorScheme="light"`, or a light page.
- **Dark**: `colorScheme="dark"`: a near-black card, slate prisms and a white “with” pill. The gradients stay.

## How it works

- Each group is an SVG of its own, drawn in px of a 203 × 300 box and scaled to its column, so the outlines, pills and prisms shrink together on a narrow card. A prism is two sides and a top, each a polygon with an objectBoundingBox gradient and a shade that darkens its lower part, over a darker band at its foot.
- A prism’s side is its value over the group’s max, of 197 px: by default the max is the larger of the two values, so the taller prism fills the height. An open value (open: true) is a floor, not a count: the top 28.5 px of its prism float off as a cap, 8.5 px above the rest, and its pill reads “150+”.
- The growth is transforms only. Each body is clipped above its plinth and starts sunk inside it; on entry it slides up in 1 s on an ease-out curve, 90 ms after the prism before. The pill rides along and fades in, and its figure counts up on the same curve, written straight into its text node every frame. The floating cap waits 820 ms, then drops 44 px with a small overshoot.
- Hovering or focusing a prism fades the others to 35% and opens a tooltip over its pill with the group’s two values, the one in focus first. The first and last prisms anchor their tooltip inward so it stays on the card.
- While on screen and untouched, the demo points at each prism in turn for 1.5 s, rests, and grows the chart again. Any pointer, key or wheel input hands it to the reader; it takes back over after 6 s of quiet. It never plays offscreen, in a hidden tab or under reduced motion.
- Every colour is a light-dark() token on the root, so the chart follows the page’s color-scheme, or the colorScheme prop. At night the card turns near-black, the grey prisms go slate, and the “with” pill turns white with dark figures; the gradient prisms keep their colours.

## API reference

- **groups** (`IsoBarsGroup[]`, default `ISO_BARS_GROUPS`): The groups, left to right. Each has a label, an icon ('chart', 'target', 'clock', any node, or null), the two values with and without, and optionally max (the value that reaches full height), unit (appended to the figures), open (the “with” value is a floor: its cap floats and its pill reads “+”) and format(value, series), which writes the figures itself.
- **product** (`string`, default `'Gridwell'`): The product the “with” series is about: the legend reads “With [mark] product”, and so do the tooltips and the prisms’ names.
- **mark** (`ReactNode | null`): The mark in the legend and, faded, on the “with” prisms’ faces: SVG content in a 14 × 14 box. A pixel diamond by default; null shows none.
- **withoutLabel** (`string`, default `'Without'`): The legend’s name for the other series.
- **animate** (`boolean`, default `true`): Grow the prisms, count the pills up and drop the caps when the chart first comes on screen. Off, it is drawn complete.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, point at each prism in turn, then grow the chart again.
- **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.
- **defaultActive** (`{ group: number; series: 'with' | 'without' }`): Opens with this prism lit, as on hover: the others dimmed and its tooltip open. It also turns the demo off.
- **className** (`string`): Extra class for the root.

## Accessibility

- The card is a figure whose caption is the legend; the groups are a list, each drawing an SVG group named after its label.
- Every prism is focusable, with role="img" and a name that gives its series and figure (“With Gridwell: 150+”). Tab or the arrow keys, Home and End move between prisms; focus shows a ring around the prism’s outline and opens the same tooltip as hover.
- The pills, the brackets, the icons and the tooltip are hidden from screen readers, which hear each prism’s name instead; the counting figures never reach them.
- Under reduced motion the chart is drawn complete, with no rise, count, drop or demo, and the dimming is instant.

## Source

### iso-bars/iso-bars.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './iso-bars.module.css';

/*
 * Isometric bars: a card that compares two series, "with" a product and "without" it, group by
 * group, as pairs of isometric prisms on a white card. The "with" prism wears a lavender-to-magenta
 * gradient and a faint pixel mark; the "without" one is plain grey. A pill over each prism gives
 * its value, and a bracket and a label sit under each group.
 *
 * - Each group is an SVG of its own, drawn in px of a 203 × 300 box and scaled to its column, so
 *   the pills and the outlines shrink with the prisms on a narrow card. A prism is three faces
 *   (two sides and a top, each a polygon with an objectBoundingBox gradient, and a darker band at
 *   the foot): its height is its value over the group's max, of 197 px.
 * - An open value ("150+": the value is a floor, not a count) splits the prism's top off as a cap
 *   that floats a few px above the rest.
 * - On entering the screen the prisms rise out of their plinths one after the other: each body is
 *   clipped above its plinth and slides up by a transform. The pills ride along and count up on
 *   the same curve, and a floating cap drops into place last, with a small overshoot.
 * - Hovering or focusing a prism dims the others and opens a tooltip with the group's two values,
 *   the one under the pointer first. Tab or the arrow keys move between prisms.
 * - While on screen and untouched, the demo points at each prism in turn, then grows the chart
 *   again. Any real input hands it to the reader. Reduced motion: no rise, no count, no demo.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type IsoBarsSeries = 'with' | 'without';

export type IsoBarsGroup = {
  /** Under the bracket, in capitals. */
  label: string;
  /** The small grey disc before the label: a built-in glyph, any node, or null for none. */
  icon?: 'chart' | 'target' | 'clock' | ReactNode;
  /** The value "with" the product, and the one "without". */
  with: number;
  without: number;
  /** The value that reaches the full height (197 px); the larger of the two by default. */
  max?: number;
  /** Appended to the values: '%', ' ms'… */
  unit?: string;
  /** The "with" value is a floor ("150+"): its prism's top floats off as a cap. */
  open?: boolean;
  /** Writes a value; it overrides unit and open's "+". Called while the pills count up too. */
  format?: (value: number, series: IsoBarsSeries) => string;
};

export const ISO_BARS_GROUPS: IsoBarsGroup[] = [
  { label: 'Data points', icon: 'chart', with: 150, without: 30, max: 171, open: true },
  { label: 'Hit rate', icon: 'target', with: 98, without: 40, unit: '%' },
];

/* The drawing, in px of each group's box. */
const W = 203;
const H = 300;
/** Half a prism's width, and half its top's height (an isometric slope of 0.6). */
const A = 39.25;
const B = A * 0.6;
/** The front corner of a prism's foot, and its side corners. */
const BASE = 298;
const CORNER = BASE - B;
/** The darker band at a prism's foot. */
const PLINTH = 5;
/** The height of the sides at the group's max. */
const FULL = 197;
/** A floating cap's sides, and the air under it. */
const CAP = 28.5;
const GAP = 8.5;
/** The pills: height, how far their middle sits over the prism's top corner. */
const PILL = 28.5;
const PILL_RISE = 34;
/** The middles of the two prisms. */
const CENTRES: Record<IsoBarsSeries, number> = { with: 48, without: 155 };
const SERIES: IsoBarsSeries[] = ['with', 'without'];

/** Timing of the growth: each prism rises in RISE ms, STAGGER after the one before. */
const RISE = 1000;
const STAGGER = 90;
const CAP_DELAY = 820;
/** The demo: a prism every STEP ms, then a rest, then the chart grows again. */
const STEP = 1500;
const REST = 1400;

const pts = (points: [number, number][]) => points.map(([x, y]) => `${x},${y}`).join(' ');
/** easeOutQuart, as the CSS transition. */
const ease = (t: number) => 1 - (1 - t) ** 4;

function decimals(value: number) {
  const text = String(value);
  return text.includes('.') ? text.split('.')[1].length : 0;
}

function write(group: IsoBarsGroup, series: IsoBarsSeries, value: number) {
  if (group.format) return group.format(value, series);
  const final = group[series];
  const text = value.toFixed(Math.min(2, decimals(final)));
  return `${text}${group.unit ?? ''}${series === 'with' && group.open ? '+' : ''}`;
}

/** A pill's width: about the text's, at 14.5 px medium, and never under 61.5. */
function pillWidth(text: string) {
  let width = 0;
  for (const char of text) width += /[0-9]/.test(char) ? 9 : char === '%' ? 12 : 8.4;
  return Math.max(61.5, width + 26);
}

/** The pixel mark: a hollow diamond on a 7 × 7 grid. */
const MARK_ROWS = ['...#...', '..###..', '.##.##.', '##...##', '.##.##.', '..###..', '...#...'];
const MARK_CELLS = MARK_ROWS.flatMap((row, y) =>
  [...row].flatMap((cell, x) => (cell === '#' ? [[x, y] as const] : [])),
);

function PixelMark({ size = 2, className }: { size?: number; className?: string }) {
  return (
    <g className={className}>
      {MARK_CELLS.map(([x, y]) => (
        <rect key={`${x}-${y}`} x={x * size} y={y * size} width={size} height={size} />
      ))}
    </g>
  );
}

/** The mark in the legend: the same diamond, in ink. */
export function IsoBarsMark({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 14 14" aria-hidden="true" shapeRendering="crispEdges">
      <PixelMark />
    </svg>
  );
}

function GroupIcon({ icon }: { icon: IsoBarsGroup['icon'] }) {
  if (icon === null) return null;
  if (icon !== undefined && typeof icon !== 'string') {
    return (
      <span className={styles.icon} aria-hidden="true">
        {icon}
      </span>
    );
  }
  return (
    <svg className={styles.icon} viewBox="0 0 16 16" aria-hidden="true">
      <circle className={styles.iconDisc} cx="8" cy="8" r="8" />
      {icon === 'target' ? (
        <path
          className={styles.iconGlyph}
          d="M8 2.6v3.6M8 9.8v3.6M2.6 8h3.6M9.8 8h3.6"
          strokeWidth="1.7"
        />
      ) : icon === 'clock' ? (
        <path className={styles.iconGlyph} d="M8 4.2V8l2.6 1.6" strokeWidth="1.6" />
      ) : (
        <path
          className={styles.iconGlyph}
          d="M5.3 8.6v3M8 5.2v6.4M10.7 7.4v4.2"
          strokeWidth="1.6"
        />
      )}
    </svg>
  );
}

type Shape = {
  /** Height of the sides, plinth included. */
  sides: number;
  /** The top corners of the body, and of the cap if any. */
  bodyTop: number;
  capTop: number | null;
  /** How far the body sinks to hide in its plinth. */
  drop: number;
  /** The highest corner: where the pill and the tooltip go. */
  top: number;
};

function shape(group: IsoBarsGroup, series: IsoBarsSeries): Shape {
  const max = group.max ?? Math.max(group.with, group.without, 1e-9);
  const open = series === 'with' && !!group.open;
  const least = PLINTH + 4 + (open ? CAP + GAP : 0);
  const sides = Math.max(least, Math.min(FULL, (FULL * Math.max(0, group[series])) / max));
  const top = CORNER - sides;
  const bodyTop = open ? top + CAP + GAP : top;
  return {
    sides,
    bodyTop,
    capTop: open ? top : null,
    drop: CORNER - PLINTH - bodyTop + 2 * B + 3,
    top,
  };
}

type Ids = { [key: string]: string };

function Prism({
  cx,
  s,
  series,
  ids,
  mark,
}: {
  cx: number;
  s: Shape;
  series: IsoBarsSeries;
  ids: Ids;
  mark: ReactNode;
}) {
  const L = cx - A;
  const R = cx + A;
  const footCorner = CORNER - PLINTH;
  const footFront = BASE - PLINTH;
  const T = s.bodyTop;
  const tinted = series === 'with';
  const leftFace = pts([
    [L, T],
    [cx, T + B],
    [cx, footFront],
    [L, footCorner],
  ]);
  const rightFace = pts([
    [cx, T + B],
    [R, T],
    [R, footCorner],
    [cx, footFront],
  ]);
  const topFace = pts([
    [L, T],
    [cx, T - B],
    [R, T],
    [cx, T + B],
  ]);
  // The mark sits in the middle of the left face's width, a little under its middle height.
  const markX = cx - A / 2;
  const markY = T + 0.6 * (A / 2) + 0.55 * (footCorner - T);
  const capped = s.capTop !== null;

  return (
    <>
      {/* The floor tile the body rises from. */}
      <polygon
        className={`${styles.edge} ${tinted ? styles.plinthTop : styles.greyPlinthTop}`}
        points={pts([
          [L, footCorner],
          [cx, footCorner - B],
          [R, footCorner],
          [cx, footFront],
        ])}
      />
      <g clipPath={`url(#${ids[`clip-${series}`]})`}>
        <g className={styles.body} data-part="prism">
          <polygon
            className={`${styles.edge} ${tinted ? '' : styles.greyLeft}`}
            fill={tinted ? `url(#${ids.left})` : undefined}
            points={leftFace}
          />
          <polygon
            className={`${styles.edge} ${tinted ? '' : styles.greyRight}`}
            fill={tinted ? `url(#${ids.right})` : undefined}
            points={rightFace}
          />
          {tinted && (
            <>
              <polygon className={styles.noStroke} fill={`url(#${ids.shade})`} points={leftFace} />
              <polygon className={styles.noStroke} fill={`url(#${ids.shade})`} points={rightFace} />
              {mark !== null && (
                <g
                  className={styles.faceMark}
                  transform={`translate(${markX} ${markY}) matrix(1 0.6 0 1 0 0) translate(-7 -7)`}
                >
                  {mark ?? <PixelMark />}
                </g>
              )}
            </>
          )}
          <polygon
            className={`${styles.edge} ${tinted ? '' : styles.greyTop}`}
            fill={tinted ? `url(#${capped ? ids.under : ids.top})` : undefined}
            points={topFace}
          />
          {tinted && !capped && (
            <polygon className={styles.noStroke} fill={`url(#${ids.topShade})`} points={topFace} />
          )}
          {capped && (
            <polygon className={styles.noStroke} fill={`url(#${ids.capShadow})`} points={topFace} />
          )}
          {/* The outline again, over the overlays. */}
          <polyline
            className={styles.line}
            points={pts([
              [L, footCorner],
              [L, T],
              [cx, T - B],
              [R, T],
              [R, footCorner],
            ])}
          />
          <polyline
            className={styles.line}
            points={pts([
              [L, T],
              [cx, T + B],
              [R, T],
            ])}
          />
          <line className={styles.line} x1={cx} y1={T + B} x2={cx} y2={footFront} />
        </g>
      </g>
      {/* The band at the foot. */}
      <polygon
        className={`${styles.edge} ${tinted ? styles.plinthLeft : styles.greyPlinthLeft}`}
        points={pts([
          [L, footCorner],
          [cx, footFront],
          [cx, BASE],
          [L, CORNER],
        ])}
      />
      <polygon
        className={`${styles.edge} ${tinted ? styles.plinthRight : styles.greyPlinthRight}`}
        points={pts([
          [cx, footFront],
          [R, footCorner],
          [R, CORNER],
          [cx, BASE],
        ])}
      />
      {capped && s.capTop !== null && (
        <g className={styles.cap} data-part="cap">
          {(() => {
            const C = s.capTop;
            const bottom = C + CAP;
            return (
              <>
                <polygon
                  className={styles.edge}
                  fill={`url(#${ids.capLeft})`}
                  points={pts([
                    [L, C],
                    [cx, C + B],
                    [cx, bottom + B],
                    [L, bottom],
                  ])}
                />
                <polygon
                  className={styles.edge}
                  fill={`url(#${ids.capRight})`}
                  points={pts([
                    [cx, C + B],
                    [R, C],
                    [R, bottom],
                    [cx, bottom + B],
                  ])}
                />
                <polygon
                  className={styles.edge}
                  fill={`url(#${ids.capTop})`}
                  points={pts([
                    [L, C],
                    [cx, C - B],
                    [R, C],
                    [cx, C + B],
                  ])}
                />
              </>
            );
          })()}
        </g>
      )}
    </>
  );
}

function Defs({ ids }: { ids: Ids }) {
  const stops = (list: [number, string, number?][]) =>
    list.map(([offset, color, opacity]) => (
      <stop key={offset} offset={offset} stopColor={color} stopOpacity={opacity ?? 1} />
    ));
  return (
    <defs>
      <linearGradient id={ids.left} x1="0" y1="0" x2="1" y2="0">
        {stops([
          [0, '#e5dcff'],
          [1, '#f4c8f7'],
        ])}
      </linearGradient>
      <linearGradient id={ids.right} x1="0" y1="0" x2="1" y2="0">
        {stops([
          [0, '#e2b3e5'],
          [1, '#eda2de'],
        ])}
      </linearGradient>
      <linearGradient id={ids.shade} x1="0" y1="0" x2="0" y2="1">
        {stops([
          [0, '#2a1438', 0],
          [0.32, '#2a1438', 0],
          [1, '#2a1438', 0.22],
        ])}
      </linearGradient>
      <linearGradient id={ids.top} x1="0" y1="0" x2="1" y2="0">
        {stops([
          [0, '#d6c2e6'],
          [1, '#e4a4da'],
        ])}
      </linearGradient>
      <linearGradient id={ids.topShade} x1="0" y1="0" x2="0" y2="1">
        {stops([
          [0, '#ffffff', 0.22],
          [0.45, '#ffffff', 0],
          [1, '#2a1438', 0.16],
        ])}
      </linearGradient>
      <linearGradient id={ids.under} x1="0" y1="0" x2="1" y2="0">
        {stops([
          [0, '#cfc2e6'],
          [1, '#d6a6d3'],
        ])}
      </linearGradient>
      <linearGradient id={ids.capShadow} x1="0" y1="0" x2="0" y2="1">
        {stops([
          [0, '#2a1438', 0.02],
          [0.5, '#2a1438', 0.1],
          [1, '#2a1438', 0.24],
        ])}
      </linearGradient>
      <linearGradient id={ids.capTop} x1="0" y1="0" x2="0" y2="1">
        {stops([
          [0, '#f6c4fb'],
          [0.5, '#fde0fe'],
          [1, '#fff3ff'],
        ])}
      </linearGradient>
      <linearGradient id={ids.capLeft} x1="0" y1="0" x2="1" y2="1">
        {stops([
          [0, '#f9e2ff'],
          [1, '#fff5ff'],
        ])}
      </linearGradient>
      <linearGradient id={ids.capRight} x1="0" y1="0" x2="1" y2="0">
        {stops([
          [0, '#ffedff'],
          [1, '#f9c0f9'],
        ])}
      </linearGradient>
      {SERIES.map((series) => {
        const cx = CENTRES[series];
        return (
          <clipPath key={series} id={ids[`clip-${series}`]}>
            <polygon
              points={pts([
                [cx - A - 4, -400],
                [cx + A + 4, -400],
                [cx + A + 4, CORNER - PLINTH],
                [cx, BASE - PLINTH],
                [cx - A - 4, CORNER - PLINTH],
              ])}
            />
          </clipPath>
        );
      })}
    </defs>
  );
}

export function IsoBars({
  groups = ISO_BARS_GROUPS,
  product = 'Gridwell',
  mark,
  withoutLabel = 'Without',
  animate = true,
  autoplay = true,
  colorScheme,
  defaultActive,
  className = '',
}: {
  /** The groups, left to right: each a pair of prisms with its label under a bracket. */
  groups?: IsoBarsGroup[];
  /** The product the "with" series is about: the legend reads "With [mark] product". */
  product?: string;
  /** The mark in the legend and, faded, on the "with" prisms (14 × 14 units); null for none. */
  mark?: ReactNode | null;
  /** The legend's name for the other series. */
  withoutLabel?: string;
  /** Grow the prisms and count the pills up when the chart first comes on screen. */
  animate?: boolean;
  /** While on screen and untouched, point at each prism in turn, then grow the chart again. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens with this prism lit, as on hover: the others dimmed, its tooltip open. No demo. */
  defaultActive?: { group: number; series: IsoBarsSeries };
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '');
  const [grown, setGrown] = useState(!animate);
  const [instant, setInstant] = useState(false);
  const [entered, setEntered] = useState(false);
  /** The prism the reader points at or focuses, and the one the demo does. */
  const [active, setActive] = useState<string | null>(
    defaultActive ? `${defaultActive.group}-${defaultActive.series}` : null,
  );
  const [demoActive, setDemoActive] = useState<string | null>(null);
  const pillTexts = useRef(new Map<string, SVGTextElement>());
  const bars = useRef(new Map<string, SVGGElement>());
  const count = useRef(0);
  /** The pills are counting up. */
  const counting = useRef(false);
  const groupsRef = useRef(groups);
  groupsRef.current = groups;

  const demo = autoplay && !defaultActive && playing && entered;
  const lit = active ?? (demo ? demoActive : null);
  const keys = groups.flatMap((_, g) => SERIES.map((series) => `${g}-${series}`));

  /** Writes every pill at a fraction of the way through the growth, from its own start. */
  const paint = useCallback((elapsed: number | null) => {
    groupsRef.current.forEach((group, g) =>
      SERIES.forEach((series, s) => {
        const text = pillTexts.current.get(`${g}-${series}`);
        if (!text) return;
        const t =
          elapsed === null ? 1 : Math.min(1, Math.max(0, (elapsed - (g * 2 + s) * STAGGER) / RISE));
        text.textContent = write(group, series, group[series] * ease(t));
      }),
    );
  }, []);

  /** Grows the chart from nothing: the prisms by CSS, the pills by a frame loop. */
  const grow = useCallback(() => {
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    cancelAnimationFrame(count.current);
    counting.current = false;
    if (reduced) {
      setInstant(true);
      setGrown(true);
      paint(null);
      return;
    }
    setInstant(true);
    setGrown(false);
    paint(0);
    counting.current = true;
    // Two frames: the collapsed state paints without transitions, then the growth runs.
    count.current = requestAnimationFrame(() => {
      count.current = requestAnimationFrame(() => {
        setInstant(false);
        setGrown(true);
        const start = performance.now();
        const total = RISE + STAGGER * (groupsRef.current.length * 2);
        const tick = (now: number) => {
          const elapsed = now - start;
          paint(elapsed);
          if (elapsed < total) count.current = requestAnimationFrame(tick);
          else counting.current = false;
        };
        count.current = requestAnimationFrame(tick);
      });
    });
  }, [paint]);

  // The first time a third of it is on screen, the chart grows.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    if (!animate) {
      setEntered(true);
      return;
    }
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        observer.disconnect();
        setEntered(true);
        grow();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, [animate, grow, root]);

  useEffect(() => () => cancelAnimationFrame(count.current), []);

  // Values changed after the growth: the pills follow at once.
  useEffect(() => {
    if (grown && !counting.current) paint(null);
  }, [groups, grown, paint]);

  // The demo: each prism in turn, a rest, then the chart grows again.
  const step = useRef(0);
  useEffect(() => {
    if (!demo) {
      setDemoActive(null);
      return;
    }
    let timer = 0;
    const next = () => {
      const all = groupsRef.current.flatMap((_, g) => SERIES.map((series) => `${g}-${series}`));
      const at = step.current % (all.length + 2);
      step.current += 1;
      if (at < all.length) {
        setDemoActive(all[at]);
        timer = window.setTimeout(next, STEP);
      } else if (at === all.length) {
        setDemoActive(null);
        timer = window.setTimeout(next, REST);
      } else {
        if (animate) grow();
        timer = window.setTimeout(next, RISE + CAP_DELAY + 900);
      }
    };
    timer = window.setTimeout(next, step.current === 0 ? RISE + CAP_DELAY + 700 : 600);
    return () => window.clearTimeout(timer);
  }, [demo, animate, grow]);

  const product_ = `With ${product}`;
  const seriesName = (series: IsoBarsSeries) => (series === 'with' ? product_ : withoutLabel);

  const onKeyDown = (event: ReactKeyboardEvent<SVGGElement>, key: string) => {
    const index = keys.indexOf(key);
    let to = -1;
    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') to = index + 1;
    if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') to = index - 1;
    if (event.key === 'Home') to = 0;
    if (event.key === 'End') to = keys.length - 1;
    if (to < 0 || to >= keys.length) return;
    event.preventDefault();
    bars.current.get(keys[to])?.focus();
  };

  const ids = (g: number): Ids => {
    const base = `isob-${uid}-${g}`;
    return Object.fromEntries(
      [
        'left',
        'right',
        'shade',
        'top',
        'topShade',
        'under',
        'capShadow',
        'capTop',
        'capLeft',
        'capRight',
        'clip-with',
        'clip-without',
      ].map((name) => [name, `${base}-${name}`]),
    );
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-grown={grown ? 'true' : 'false'}
      data-instant={instant ? 'true' : undefined}
      onPointerDown={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
      onKeyDown={take}
      onWheel={take}
    >
      <figure className={styles.card} data-part="card">
        <figcaption className={styles.legend} data-part="legend">
          <span className={styles.legendItem}>
            <span className={`${styles.dot} ${styles.dotWith}`} aria-hidden="true" />
            <span className={styles.legendText}>
              With
              {/* With no mark, "With" and the product are one run of text: keep a space. */}
              {mark === null && ' '}
              {mark !== null &&
                (mark === undefined ? (
                  <IsoBarsMark className={styles.legendMark} />
                ) : (
                  <svg className={styles.legendMark} viewBox="0 0 14 14" aria-hidden="true">
                    {mark}
                  </svg>
                ))}
              {product}
            </span>
          </span>
          <span className={styles.legendItem}>
            <span className={`${styles.dot} ${styles.dotWithout}`} aria-hidden="true" />
            <span className={styles.legendText}>{withoutLabel}</span>
          </span>
        </figcaption>

        <ul className={styles.groups} role="list">
          {groups.map((group, g) => {
            const groupIds = ids(g);
            const shapes = { with: shape(group, 'with'), without: shape(group, 'without') };
            const litHere = lit?.startsWith(`${g}-`) ? (lit.split('-')[1] as IsoBarsSeries) : null;
            return (
              <li key={`${group.label}-${g}`} className={styles.group} data-part="group">
                <div className={styles.plot}>
                  <svg
                    className={styles.svg}
                    viewBox={`0 0 ${W} ${H}`}
                    role="group"
                    aria-label={group.label}
                    data-lit={litHere ? 'true' : undefined}
                  >
                    <Defs ids={groupIds} />
                    {SERIES.map((series, s) => {
                      const key = `${g}-${series}`;
                      const sh = shapes[series];
                      const cx = CENTRES[series];
                      const finalText = write(group, series, group[series]);
                      const width = pillWidth(finalText);
                      // The outlined pill's stroke straddles its edge: it grows to match.
                      const grow = series === 'with' ? 0 : 0.5;
                      const pillY = sh.top - B - PILL_RISE;
                      const order = g * 2 + s;
                      const silhouette = pts([
                        [cx - A, sh.top],
                        [cx, sh.top - B],
                        [cx + A, sh.top],
                        [cx + A, CORNER],
                        [cx, BASE],
                        [cx - A, CORNER],
                      ]);
                      return (
                        <g
                          key={series}
                          ref={(element) => {
                            if (element) bars.current.set(key, element);
                            else bars.current.delete(key);
                          }}
                          className={styles.bar}
                          data-part={`bar-${series}`}
                          data-series={series}
                          data-lit={litHere === series ? 'true' : undefined}
                          data-dim={litHere && litHere !== series ? 'true' : undefined}
                          style={
                            {
                              '--delay': `${order * STAGGER}ms`,
                              '--drop': `${sh.drop}px`,
                            } as CSSProperties
                          }
                          tabIndex={0}
                          role="img"
                          aria-label={`${seriesName(series)}: ${finalText}`}
                          onPointerEnter={() => setActive(key)}
                          onPointerLeave={() => setActive((now) => (now === key ? null : now))}
                          onFocus={() => setActive(key)}
                          onBlur={() => setActive((now) => (now === key ? null : now))}
                          onKeyDown={(event) => onKeyDown(event, key)}
                        >
                          <polygon className={styles.ring} points={silhouette} />
                          <polygon className={styles.hit} points={silhouette} />
                          <Prism cx={cx} s={sh} series={series} ids={groupIds} mark={mark} />
                          <g className={styles.pill} data-part="pill" aria-hidden="true">
                            <rect
                              className={series === 'with' ? styles.pillWith : styles.pillWithout}
                              x={cx - width / 2 - grow}
                              y={pillY - PILL / 2 - grow}
                              width={width + grow * 2}
                              height={PILL + grow * 2}
                              rx={PILL / 2 + grow}
                            />
                            <text
                              ref={(element) => {
                                if (element) pillTexts.current.set(key, element);
                                else pillTexts.current.delete(key);
                              }}
                              className={
                                series === 'with' ? styles.pillTextWith : styles.pillTextWithout
                              }
                              x={cx}
                              y={pillY}
                              dy="0.36em"
                              textAnchor="middle"
                            >
                              {finalText}
                            </text>
                          </g>
                        </g>
                      );
                    })}
                  </svg>
                  {litHere && (
                    <Tip
                      group={group}
                      series={litHere}
                      seriesName={seriesName}
                      top={shapes[litHere].top - B - PILL_RISE - PILL / 2 - 10}
                      x={CENTRES[litHere]}
                      align={
                        g === 0 && litHere === 'with'
                          ? 'start'
                          : g === groups.length - 1 && litHere === 'without'
                            ? 'end'
                            : 'middle'
                      }
                    />
                  )}
                </div>
                <span className={styles.bracket} aria-hidden="true" data-part="bracket" />
                <span className={styles.label} data-part="label">
                  <GroupIcon icon={group.icon === undefined ? 'chart' : group.icon} />
                  {group.label}
                </span>
              </li>
            );
          })}
        </ul>
      </figure>
    </div>
  );
}

function Tip({
  group,
  series,
  seriesName,
  top,
  x,
  align,
}: {
  group: IsoBarsGroup;
  series: IsoBarsSeries;
  seriesName: (series: IsoBarsSeries) => string;
  top: number;
  x: number;
  align: 'start' | 'middle' | 'end';
}) {
  const order: IsoBarsSeries[] = series === 'with' ? ['with', 'without'] : ['without', 'with'];
  return (
    <div
      className={styles.tip}
      data-part="tooltip"
      data-align={align}
      aria-hidden="true"
      style={{ left: `${(x / W) * 100}%`, top: `${(top / H) * 100}%` }}
    >
      <span className={styles.tipTitle}>{group.label}</span>
      {order.map((s) => (
        <span key={s} className={styles.tipRow} data-current={s === series ? 'true' : undefined}>
          <span className={`${styles.dot} ${s === 'with' ? styles.dotWith : styles.dotWithout}`} />
          <span className={styles.tipName}>{seriesName(s)}</span>
          <span className={styles.tipValue}>{write(group, s, group[s])}</span>
        </span>
      ))}
    </div>
  );
}
```

### iso-bars/iso-bars.module.css

```css
/* Isometric bars (iso-bars.tsx): a white card on a pale grey stage, or at night a near-black one.
   The card is laid out in px for a width of 622; narrower, its padding and the gaps shrink, and
   each group's drawing (an SVG of 203 × 300) scales down with its column.

   Colours are the tokens below, light-dark(light, dark) where the two differ: 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 "with" prisms' gradients are the same on both
   schemes, and live in the SVG.

   Memory. The prisms, pills and caps move by transforms and opacity only; the pills' figures are
   written straight into their text nodes while they count. */

.root {
  /* The stage, and the card on it with its edge. */
  --stage: #f4f6f8;
  --card: #fff;
  --card-edge: #e7e9e9;
  /* The legend's words; the labels under the groups and their discs; the brackets. */
  --ink: #0f0f10;
  --label: #7a7c7f;
  --icon: #bfc1c3;
  --icon-glyph: #fff;
  --bracket: #e6e6e6;
  /* The prisms' outline. */
  --outline: #0b0b0c;
  /* The "without" prisms: top, left, right and foot. */
  --grey-top: #f6f6f6;
  --grey-left: #d9d9d9;
  --grey-right: #c7c7c7;
  --grey-foot: #a6a6a6;
  /* The "with" prisms' foot, left and right; the mark on their face. */
  --foot-left: #7d7197;
  --foot-right: #904f85;
  --face-mark: rgb(70 40 95 / 0.26);
  /* The pills: "with" filled, "without" outlined. */
  --pill-with: #000;
  --pill-with-ink: #fff;
  --pill-without: #fff;
  --pill-without-edge: #e6e6e6;
  --pill-without-ink: #0f0f10;
  /* The legend's "without" dot. */
  --dot-without: #ececf2;
  /* The tooltip: its face, edge and shadow; its muted words. */
  --tip: #fff;
  --tip-edge: rgb(0 0 0 / 0.07);
  --tip-shadow: rgb(0 0 0 / 0.1);
  --tip-muted: #7a7c80;
  --focus: #387bff;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 100%;
  padding: clamp(16px, 5vw, 48px) 16px;
  box-sizing: border-box;
  container-type: inline-size;
  background: var(--stage);
  color: var(--ink);
  font-family: 'Library Inter', Inter, ui-sans-serif, system-ui, sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f4f6f8, #0c0c0e);
    --card: light-dark(#fff, #161618);
    --card-edge: light-dark(#e7e9e9, rgb(255 255 255 / 0.08));
    --ink: light-dark(#0f0f10, #ececef);
    --label: light-dark(#7a7c7f, #95979c);
    --icon: light-dark(#bfc1c3, #4a4c51);
    --icon-glyph: light-dark(#fff, #161618);
    --bracket: light-dark(#e6e6e6, #2e2e33);
    --outline: light-dark(#0b0b0c, #050506);
    --grey-top: light-dark(#f6f6f6, #55555c);
    --grey-left: light-dark(#d9d9d9, #404046);
    --grey-right: light-dark(#c7c7c7, #33333a);
    --grey-foot: light-dark(#a6a6a6, #26262b);
    --pill-with: light-dark(#000, #fafafa);
    --pill-with-ink: light-dark(#fff, #0b0b0c);
    --pill-without: light-dark(#fff, #161618);
    --pill-without-edge: light-dark(#e6e6e6, #3a3a40);
    --pill-without-ink: light-dark(#0f0f10, #ececef);
    --dot-without: light-dark(#ececf2, #3a3a40);
    --tip: light-dark(#fff, #222226);
    --tip-edge: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.08));
    --tip-shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
    --tip-muted: light-dark(#7a7c80, #96989e);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.card {
  container-type: inline-size;
  width: min(622px, 100%);
  margin: 0;
  padding: 76px 0 64px;
  box-sizing: border-box;
  border: 1px solid var(--card-edge);
  border-radius: 24px;
  background: var(--card);
}

/* The legend: "● With [mark] Name   ● Without". */
.legend {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px 31.5px;
  padding: 0 16px;
  font-size: 15px;
  font-weight: 500;
  line-height: 20px;
  letter-spacing: -0.02em;
}
.legendItem {
  display: inline-flex;
  align-items: center;
  gap: 10px;
}
.legendText {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  white-space: nowrap;
}
.legendMark {
  width: 16.5px;
  height: 16.5px;
  fill: currentColor;
}
.dot {
  flex: none;
  width: 14px;
  height: 14px;
  border-radius: 50%;
}
.dotWith {
  background: linear-gradient(90deg, #e2dcfc, #fdbbf3);
}
.dotWithout {
  background: var(--dot-without);
}

/* The groups, side by side under the legend. */
.groups {
  display: flex;
  justify-content: center;
  gap: 59.5px;
  width: min(465.5px, calc(100% - 32px));
  margin: 99.4px auto 0;
  padding: 0;
  list-style: none;
}
.group {
  display: flex;
  flex: 0 1 203px;
  flex-direction: column;
  align-items: center;
  min-width: 0;
}
.plot {
  position: relative;
  width: 100%;
  aspect-ratio: 203 / 300;
}
.svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.bracket {
  position: relative;
  width: 100%;
  height: 8px;
  margin-top: 48.4px;
  box-sizing: border-box;
  border-inline: 1.25px solid var(--bracket);
}
.bracket::before {
  position: absolute;
  inset: 50% 0 auto;
  height: 1.25px;
  background: var(--bracket);
  content: '';
  translate: 0 -50%;
}
.label {
  display: inline-flex;
  align-items: center;
  gap: 9.5px;
  margin-top: 23.2px;
  color: var(--label);
  font-size: 13px;
  font-weight: 600;
  line-height: 17px;
  letter-spacing: 0.015em;
  text-align: center;
  text-transform: uppercase;
}
.icon {
  display: inline-grid;
  flex: none;
  place-items: center;
  width: 16.5px;
  height: 16.5px;
}
.iconDisc {
  fill: var(--icon);
}
.iconGlyph {
  fill: none;
  stroke: var(--icon-glyph);
  stroke-linecap: round;
}

/* The prisms. */
.bar {
  outline: none;
  cursor: default;
  transition: opacity 220ms ease;
}
.bar[data-dim='true'] {
  opacity: 0.35;
}
.hit {
  fill: transparent;
  pointer-events: all;
}
.ring {
  fill: none;
  stroke: transparent;
  stroke-width: 5;
  stroke-linejoin: round;
  pointer-events: none;
}
.bar:focus-visible .ring {
  stroke: var(--focus);
}
.edge {
  stroke: var(--outline);
  stroke-width: 1.25;
  stroke-linejoin: round;
}
.line {
  fill: none;
  stroke: var(--outline);
  stroke-width: 1.25;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.noStroke {
  stroke: none;
}
.greyTop {
  fill: var(--grey-top);
}
.greyLeft {
  fill: var(--grey-left);
}
.greyRight {
  fill: var(--grey-right);
}
.greyPlinthTop,
.greyPlinthLeft,
.greyPlinthRight {
  fill: var(--grey-foot);
}
.plinthTop {
  fill: #b7a0cc;
}
.plinthLeft {
  fill: var(--foot-left);
}
.plinthRight {
  fill: var(--foot-right);
}
.faceMark {
  fill: var(--face-mark);
  shape-rendering: crispEdges;
}

/* The pills. */
.pillWith {
  fill: var(--pill-with);
}
.pillWithout {
  fill: var(--pill-without);
  stroke: var(--pill-without-edge);
  stroke-width: 1.5;
}
.pillTextWith,
.pillTextWithout {
  font-size: 14.5px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.03em;
}
.pillTextWith {
  fill: var(--pill-with-ink);
}
.pillTextWithout {
  fill: var(--pill-without-ink);
}

/* Growth: each body slides up out of its plinth, its pill riding along; a floating cap drops in
   last. Collapsed, they wait below (or above) with no transition, so the growth starts clean. */
.body,
.pill {
  transition:
    transform 1000ms cubic-bezier(0.25, 1, 0.5, 1) var(--delay),
    opacity 360ms ease calc(var(--delay) + 140ms);
}
.cap {
  transition:
    transform 560ms cubic-bezier(0.34, 1.5, 0.64, 1) calc(var(--delay) + 820ms),
    opacity 200ms ease calc(var(--delay) + 820ms);
}
.root[data-grown='false'] .body {
  transform: translateY(var(--drop));
}
.root[data-grown='false'] .pill {
  opacity: 0;
  transform: translateY(var(--drop));
}
.root[data-grown='false'] .cap {
  opacity: 0;
  transform: translateY(-44px);
}
.root[data-instant='true'] .body,
.root[data-instant='true'] .pill,
.root[data-instant='true'] .cap {
  transition: none;
}

/* The tooltip, over the prism's pill. */
.tip {
  position: absolute;
  z-index: 2;
  display: grid;
  gap: 4px;
  min-width: 148px;
  padding: 8px 10px 9px;
  box-sizing: border-box;
  border: 1px solid var(--tip-edge);
  border-radius: 10px;
  background: var(--tip);
  box-shadow:
    0 1px 2px var(--tip-shadow),
    0 8px 24px var(--tip-shadow);
  font-size: 12.5px;
  line-height: 16px;
  white-space: nowrap;
  pointer-events: none;
  translate: -50% -100%;
  animation: tipIn 160ms ease-out;
}
.tip[data-align='start'] {
  translate: -22% -100%;
}
.tip[data-align='end'] {
  translate: -78% -100%;
}
.tipTitle {
  color: var(--tip-muted);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.tipRow {
  display: grid;
  grid-template-columns: 8px 1fr auto;
  align-items: center;
  gap: 7px;
  color: var(--tip-muted);
}
.tipRow[data-current='true'] {
  color: var(--ink);
}
.tipRow .dot {
  width: 8px;
  height: 8px;
}
.tipValue {
  margin-left: 10px;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}
@keyframes tipIn {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
}

/* Narrow cards: less air around the groups and between them. */
@container (max-width: 520px) {
  .groups {
    gap: 9%;
    margin-top: 64px;
  }
  .card {
    padding-top: 48px;
  }
  .label {
    gap: 7px;
    font-size: 12px;
  }
  .bracket {
    margin-top: 36px;
  }
}
@container (max-width: 380px) {
  .groups {
    margin-top: 56px;
  }
  .label {
    font-size: 11px;
    letter-spacing: 0.02em;
  }
  .icon {
    width: 14px;
    height: 14px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .body,
  .pill,
  .cap,
  .bar {
    transition: none;
  }
  .tip {
    animation: none;
  }
}
```

### iso-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 };
}
```