# Contracts table

> A dark contracts table with a notice-date timeline per row, approval circles, risk bars and a floating action bar for the selection.

The contracts a team keeps, as one card. Tabs filter by status, and each row shows its next six months on a small timeline: a bar up to the last day to give notice, a hatched window to the renewal, a tick on that day, and a dashed line for today. Notice dates three weeks off turn amber. Select rows and an action bar slides up to remind owners, extend notice or open a review; sort by notice date or risk and the rows glide into place.

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

Category: Data.

## Anatomy

1. **Header**: The title, the count of contracts with the playbook they follow, and New review.
2. **Status tabs**: All, Renewing, In review and Signed with live counts; one pill slides under the active tab and arrow keys move between them.
3. **Sort label**: Says the current order and switches between notice date and risk; the Notice and Risk headers sort too.
4. **Contract cell**: The row header: the contract’s name, then its kind of paper and yearly value in thousands.
5. **Timeline**: 124 px for October to March: a bar up to the notice date, a hatched window to the renewal and its tick, today as a dashed line. Amber within 21 days.
6. **Approvals**: Four 18 px circles in sign-off order: approved, in review (turning spokes), changes requested, waiting. Focus lists all four in a tooltip.
7. **Risk**: The score and five bars, one per 20 points: grey under 50, amber to 69, red from 70.
8. **Action bar**: Slides up over the list’s faded bottom while anything is selected: the count, Remind owner, Extend notice and Open review.

## When to use

- A legal or procurement queue where the date that matters is a deadline to act (the last day to give notice) before something happens on its own (the renewal).
- Rows that each carry a short sign-off chain (Legal, Finance, Security, Owner) and need to show where it stops at a glance, with the detail one hover away.
- Bulk work on a filtered list: tick a few contracts, then remind their owners, push their notice dates or open their reviews together from one bar.
- A few dozen to a few hundred items that people re-sort between two questions, what is due first and what is riskiest, and need to see the order change.

## When not to use

- One contract’s detail. Use a review page with its clauses, redlines and the full approval history.
- Dates spread over years or many events per row. Use a Gantt or project timeline; the track here covers six months and three marks.
- Thousands of rows with free filtering and column picking. Use a data grid with virtual scrolling and server-side sort.
- A plain list with no deadlines, approvals or risk. Use a simple table or a list; the timeline and circles would be noise.
- Phones as the main screen. Under 500 px the table scrolls sideways; give mobile a card list grouped by notice date instead.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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 { ContractsTable } from './contracts-table';

export function Renewals() {
  return (
    <ContractsTable
      defaultTab="renewing"
      defaultSelected={[]}
      autoplay={false}
      onOpenReview={(contracts) => console.log(contracts.map((c) => c.id))}
    />
  );
}
```

## States

- **One selected**: The Renewing tab sorted by notice date, Halden Works ticked: the header box is mixed and the action bar is up.
- **Nothing selected**: `defaultSelected={[]}`: the action bar waits under the frame, inert, and the list reads to its faded end.
- **All selected**: The header box ticks every row in the tab; the count reads 9 and the bar acts on all of them.
- **Row hover**: The row lights under the pointer; clicking anywhere on it but its controls toggles it.
- **Approvals tooltip**: Focus on a row’s approvals lists the four sign-offs; hovering one circle shows only its own.
- **Sorted by risk**: Highest risk first, ties by notice date; on a live table the rows glide to their places.
- **Notice extended**: Extend notice moved Halden Works 30 days later, to Dec 1: the window and tick slid right and the amber is gone.
- **Reminder sent**: Remind owner calls `onRemind`, says Reminder sent for 1.8 s and announces who was reminded.
- **In review tab**: Another tab fades its rows in one after another; the selection stays, so the bar keeps its count.
- **Signed tab**: A long list scrolls inside the card under its sticky header and fades out at the bottom.
- **Light**: `colorScheme="light"`: a white card on a pale grey stage, from the same light-dark() tokens.
- **Dark**: `colorScheme="dark"`: a near-black card on a black stage.
- **Narrow**: Under 500 px of card the table scrolls sideways in its frame, the subtitle wraps and the action bar spans the frame.

## How it works

- A real table with fixed column widths. The timeline is 124 px for the six months from October 1, so a date is a position: the grey bar runs to the notice date, the hatched window from there to the renewal (a 45° repeating gradient) and a 2 px tick marks the renewal. Today’s dashed line is one repeating gradient per row, at the same x.
- Extend notice moves the selected contracts 30 days later. The window and the tick slide by a transform and the grey bar grows to meet them, over 600 ms; a notice date that is no longer close loses its amber.
- Sorting is FLIP: before the change each row’s place is noted, after it each row is moved back where it was by a transform and played to zero by the Web Animations API in 420 ms. A new tab fades its rows in instead, 28 ms apart.
- The tabs’ pill is one element that slides to the active tab and takes its width; arrow keys move between tabs.
- The action bar is always there, translated below the frame and transparent; any selection slides it up in 380 ms, with the count rising into place when it changes. The bottom of the list fades to the frame’s colour under it, and the list scrolls under a sticky header.
- Approvals are four 18 px circles: approved (green check), in review (eight spokes turning in steps), changes requested (red cross) and waiting (dashed ring). Hovering one shows its step and state; focusing the cell lists all four.
- While on screen and untouched, a demo pointer extends a notice, selects a row, reads an approval, sorts by risk, selects all and visits another tab, then starts again. Any key, press or wheel hands it to the reader.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme or the colorScheme prop. Under 500 px the table scrolls sideways inside its frame, the subtitle wraps and the action bar spans the frame.

## API reference

- **contracts** (`Contract[]`, default `CONTRACTS`): The rows: name, type, value in $k, status, notice and renewal days from October 1, four approvals, risk 0–100 and owner. The tab counts come from them.
- **playbook** (`string`, default `'Playbook SaaS vendor v3'`): The playbook the reviews follow, under the title.
- **defaultTab** (`'all' | 'renewing' | 'review' | 'signed'`, default `'renewing'`): The tab open at first.
- **defaultSelected** (`string[]`, default `['halden']`): Ids of the contracts selected at first.
- **onNewReview** (`() => void`): Called by the New review button.
- **onOpenReview** (`(contracts: Contract[]) => void`): Called by Open review, with the selected contracts.
- **onRemind** (`(contracts: Contract[]) => void`): Called by Remind owner, with the selected contracts.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer goes through the table: extend, select, sort, select all, change tabs.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **defaultState** (`{ sort?: 'notice' | 'risk'; extended?: string[]; tip?: string; hover?: string; reminded?: boolean }`): Opens on a moment: a sort, contracts already extended by 30 days, a row’s approvals tooltip open, a hovered row, or Reminder sent. Giving it keeps the demo off.
- **className** (`string`): Extra class for the root.

## Accessibility

- A real table: column headers, each contract’s name as its row header, aria-sort on the sorted column. Notice and Risk in the header are buttons that sort; so is the “Sorted by” label.
- The tabs are a tablist with arrow keys and roving focus, controlling the table as its tabpanel.
- Each row and the header have real checkboxes (the header one is mixed when some rows are chosen), named after their contract. Escape in the table clears the selection.
- The action bar is a toolbar, inert while nothing is selected. Sorting, tab changes, reminders and extensions are announced in a polite live region.
- Each timeline is an image named with its dates (“Notice by Oct 31, renews Dec 30”); the approvals cell is focusable, named with all four states, and shows them in a tooltip on focus.
- Focus rings show for keyboard use. Under reduced motion nothing glides, fades or spins, and the demo never plays.

## Source

### contracts-table/contracts-table.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import {
  CONTRACTS,
  SPAN,
  STEPS,
  TICKS,
  TODAY,
  dayLabel,
  type Approval,
  type Contract,
  type Status,
} from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './contracts-table.module.css';

/*
 * Contracts table: the contracts a team keeps, as one dark card. Tabs filter it by status, a
 * header checkbox and one per row select contracts, and a floating action bar slides up with
 * what can be done to the selection.
 *
 * - Each row draws its six months ahead on a small timeline: a grey bar up to the last day to
 *   give notice, a hatched window from there to the day the contract renews on its own, and a
 *   tick on that day. A dashed line marks today. Notice dates three weeks off or closer turn
 *   amber, bar and all.
 * - Approvals are four circles (Legal, Finance, Security, Owner): approved, in review (a
 *   spinning mark), changes requested, or still waiting. Hovering a circle, or focusing the
 *   cell, shows a tooltip with where each one stands.
 * - Risk is a number with five bars: grey under 50, amber to 69, red from 70.
 * - Sorting (by notice date, or by risk) reorders the rows in place: each row glides from where
 *   it was to where it lands (FLIP, played by the Web Animations API). Changing tabs fades the
 *   new rows in, one after another.
 * - Extend notice moves the selected contracts' notice date, and their renewal, 30 days later:
 *   the hatched window and its tick slide right along the timeline.
 * - The list scrolls inside the card under a sticky header, and fades out at the bottom, under
 *   the action bar.
 * - While on screen and untouched, a demo pointer goes through it: extends a notice, selects,
 *   reads an approval, sorts by risk, selects all, opens another tab. Any real input hands
 *   control to the reader.
 * - Every colour is a light-dark() token on the root: the page's color-scheme, or the
 *   colorScheme prop.
 */

type Tab = 'all' | Status;
type Sort = 'notice' | 'risk';

const TABS: { id: Tab; label: string }[] = [
  { id: 'all', label: 'All' },
  { id: 'renewing', label: 'Renewing' },
  { id: 'review', label: 'In review' },
  { id: 'signed', label: 'Signed' },
];

/** The timeline's width in px: SPAN days across. */
const TRACK = 124;
/** Notice dates this many days off or closer are drawn in amber. */
const SOON = 21;
/** How far Extend notice moves a contract, in days. */
const EXTEND = 30;

const STATE_WORDS: Record<Approval, string> = {
  done: 'Approved',
  active: 'In review',
  rejected: 'Changes requested',
  waiting: 'Waiting',
};

const x = (day: number) => (Math.min(Math.max(day, 0), SPAN) / SPAN) * TRACK;
const riskLevel = (risk: number) => (risk >= 70 ? 'high' : risk >= 50 ? 'medium' : 'low');

type Tip = { x: number; y: number; lines: string[] };

/** A moment to open on, for docs and stills; giving one also keeps the demo off. */
export type ContractsTableState = {
  /** The sort, notice date (the default) or risk. */
  sort?: Sort;
  /** Ids of contracts whose notice has already been extended once. */
  extended?: string[];
  /** The id of a contract whose approvals tooltip is open, all four lines. */
  tip?: string;
  /** The id of a row drawn as hovered. */
  hover?: string;
  /** Remind owner shows Reminder sent. */
  reminded?: boolean;
};

export function ContractsTable({
  contracts = CONTRACTS,
  playbook = 'Playbook SaaS vendor v3',
  defaultTab = 'renewing',
  defaultSelected = ['halden'],
  onNewReview,
  onOpenReview,
  onRemind,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The rows. Dates are days from October 1; see data.ts. */
  contracts?: Contract[];
  /** The playbook the reviews follow, under the title. */
  playbook?: string;
  /** The tab open at first. */
  defaultTab?: Tab;
  /** Ids of the contracts selected at first. */
  defaultSelected?: string[];
  /** Called by the New review button. */
  onNewReview?: () => void;
  /** Called by Open review, with the selected contracts. */
  onOpenReview?: (contracts: Contract[]) => void;
  /** Called by Remind owner, with the selected contracts. */
  onRemind?: (contracts: Contract[]) => void;
  /** While on screen and untouched, a demo pointer goes through the table. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens on a moment (a sort, an extension, an open tooltip…) and keeps the demo off. */
  defaultState?: ContractsTableState;
  className?: string;
}) {
  /** A moment to open on keeps the demo off. */
  const demo = autoplay && !defaultState;
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [tab, setTab] = useState<Tab>(defaultTab);
  const [sort, setSort] = useState<Sort>(defaultState?.sort ?? 'notice');
  const [selected, setSelected] = useState<Set<string>>(() => new Set(defaultSelected));
  /** Days each contract's notice has been extended by. */
  const [shift, setShift] = useState<Record<string, number>>(() =>
    Object.fromEntries((defaultState?.extended ?? []).map((id) => [id, EXTEND])),
  );
  const [reminded, setReminded] = useState(defaultState?.reminded ?? false);
  const [tip, setTip] = useState<Tip | null>(null);
  /** The row the demo pointer stands over, drawn as hovered. */
  const [demoHover, setDemoHover] = useState<string | null>(defaultState?.hover ?? null);
  const [message, setMessage] = useState('');
  const card = useRef<HTMLElement>(null);
  const scroller = useRef<HTMLDivElement>(null);
  const tablist = useRef<HTMLDivElement>(null);
  const indicator = useRef<HTMLSpanElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const rows = useRef(new Map<string, HTMLTableRowElement>());
  /** Where each row stood before a re-sort, for FLIP. */
  const before = useRef<Map<string, number> | null>(null);
  const remindTimer = useRef(0);
  const uid = useId();
  /** The bar keeps its last count while it slides away. */
  const lastCount = useRef(1);

  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  const live = useMemo(
    () =>
      contracts.map((contract) => {
        const days = shift[contract.id] ?? 0;
        return days
          ? { ...contract, notice: contract.notice + days, renews: contract.renews + days }
          : contract;
      }),
    [contracts, shift],
  );
  const counts = useMemo(() => {
    const result: Record<Tab, number> = { all: live.length, renewing: 0, review: 0, signed: 0 };
    for (const contract of live) result[contract.status] += 1;
    return result;
  }, [live]);
  const visible = useMemo(() => {
    const list = live.filter((contract) => tab === 'all' || contract.status === tab);
    return list.sort((a, b) =>
      sort === 'risk' ? b.risk - a.risk || a.notice - b.notice : a.notice - b.notice,
    );
  }, [live, tab, sort]);
  const chosen = live.filter((contract) => selected.has(contract.id));
  if (chosen.length) lastCount.current = chosen.length;
  const allChosen = visible.length > 0 && visible.every((contract) => selected.has(contract.id));
  const someChosen = !allChosen && visible.some((contract) => selected.has(contract.id));

  /** Notes where every row stands, so the next render can glide them to their new places. */
  const capture = () => {
    const map = new Map<string, number>();
    rows.current.forEach((row, id) => map.set(id, row.getBoundingClientRect().top));
    before.current = map;
  };

  useLayoutEffect(() => {
    const first = before.current;
    before.current = null;
    if (!first || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    rows.current.forEach((row, id) => {
      const top = first.get(id);
      if (top === undefined) return;
      const dy = top - row.getBoundingClientRect().top;
      if (Math.abs(dy) < 1) return;
      row.animate([{ transform: `translateY(${dy}px)` }, { transform: 'translateY(0)' }], {
        duration: 420,
        easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)',
      });
    });
  }, [visible]);

  // The active tab's pill slides under it.
  useLayoutEffect(() => {
    const list = tablist.current;
    const pill = indicator.current;
    if (!list || !pill) return;
    const place = () => {
      const active = list.querySelector<HTMLElement>('[aria-selected="true"]');
      if (!active) return;
      pill.style.width = `${active.offsetWidth}px`;
      pill.style.transform = `translateX(${active.offsetLeft}px)`;
    };
    place();
    const observer = new ResizeObserver(place);
    observer.observe(list);
    return () => observer.disconnect();
  }, [tab]);

  useEffect(() => () => window.clearTimeout(remindTimer.current), []);

  const chooseTab = (next: Tab) => {
    if (next === tab) return;
    setTab(next);
    setTip(null);
    if (scroller.current) scroller.current.scrollTop = 0;
    const count = live.filter((contract) => next === 'all' || contract.status === next).length;
    announce(`${TABS.find((item) => item.id === next)?.label}: ${count} contracts.`);
  };

  const chooseSort = (next: Sort) => {
    if (next === sort) return;
    capture();
    setSort(next);
    announce(next === 'risk' ? 'Sorted by risk, highest first.' : 'Sorted by notice date.');
  };

  const toggle = (id: string) =>
    setSelected((now) => {
      const next = new Set(now);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  const toggleAll = () =>
    setSelected((now) => {
      const next = new Set(now);
      for (const contract of visible) {
        if (allChosen) next.delete(contract.id);
        else next.add(contract.id);
      }
      return next;
    });

  const extend = () => {
    if (!chosen.length) return;
    if (sort === 'notice') capture();
    setShift((now) => {
      const next = { ...now };
      for (const contract of chosen) next[contract.id] = (next[contract.id] ?? 0) + EXTEND;
      return next;
    });
    announce(
      chosen.length === 1
        ? `Notice for ${chosen[0].name} moved to ${dayLabel(chosen[0].notice + EXTEND)}.`
        : `Notice moved ${EXTEND} days later for ${chosen.length} contracts.`,
    );
  };

  const remind = () => {
    if (!chosen.length) return;
    onRemind?.(chosen);
    const owners = [...new Set(chosen.map((contract) => contract.owner))];
    setReminded(true);
    window.clearTimeout(remindTimer.current);
    remindTimer.current = window.setTimeout(() => setReminded(false), 1800);
    announce(`Reminder sent to ${owners.join(', ')}.`);
  };

  const open = () => {
    if (!chosen.length) return;
    onOpenReview?.(chosen);
    announce(
      chosen.length === 1
        ? `Opening the review of ${chosen[0].name}.`
        : `Opening ${chosen.length} reviews.`,
    );
  };

  /** A tooltip over `target`, in the card's px. */
  const showTip = (target: HTMLElement, lines: string[]) => {
    const frame = card.current?.getBoundingClientRect();
    if (!frame) return;
    const box = target.getBoundingClientRect();
    // In the card's own px, even when it is drawn scaled.
    const scale = frame.width / (card.current?.offsetWidth || frame.width) || 1;
    setTip({
      x: (box.left + box.width / 2 - frame.left) / scale,
      y: (box.top - frame.top) / scale,
      lines,
    });
  };

  // A tooltip to open on, placed once the rows are laid out.
  const openTip = defaultState?.tip;
  const placeTip = useEffectEvent(() => {
    const contract = live.find((item) => item.id === openTip);
    const cell = root.current?.querySelector<HTMLElement>(
      `[data-row="${openTip}"] [data-part="approvals"]`,
    );
    if (contract && cell)
      showTip(
        cell,
        contract.approvals.map((state, step) => `${STEPS[step]} · ${STATE_WORDS[state]}`),
      );
  });
  useEffect(() => {
    if (openTip) placeTip();
  }, [openTip]);

  const onTableKey = (event: ReactKeyboardEvent) => {
    if (event.key === 'Escape' && selected.size) {
      setSelected(new Set());
      announce('Selection cleared.');
    }
  };

  // The demo: a script of glides and presses over the live controls.
  const demoStep = useEffectEvent((name: string) => {
    if (name === 'reset') {
      setTab(defaultTab);
      setSort('notice');
      setSelected(new Set(defaultSelected));
      setShift({});
      setTip(null);
      setDemoHover(null);
    } else if (name === 'extend') extend();
    else if (name === 'mosaic') toggle('mosaic');
    else if (name === 'all') toggleAll();
    else if (name === 'risk') chooseSort('risk');
    else if (name === 'notice') chooseSort('notice');
    else if (name === 'review') chooseTab('review');
    else if (name === 'renewing') chooseTab('renewing');
    else if (name === 'tip') {
      const circle = root.current?.querySelector<HTMLElement>(
        '[data-row="halden"] [data-step="0"]',
      );
      if (circle) showTip(circle, [`${STEPS[0]} · ${STATE_WORDS.active}`]);
    } else if (name === 'untip') setTip(null);
  });
  const demoHoverRow = useEffectEvent((id: string | null) => setDemoHover(id));

  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !playing || !stage || !arrow) return;
    const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
    /** The middle of `element`, in the stage's px, if it can be seen in the card. */
    const spot = (element: HTMLElement | null): Point | null => {
      if (!element) return null;
      const frame = stage.getBoundingClientRect();
      const box = element.getBoundingClientRect();
      const view = scroller.current?.getBoundingClientRect();
      const inside =
        !element.closest('[data-part="table"]') ||
        !view ||
        (box.left >= view.left && box.right <= view.right && box.bottom <= view.bottom);
      if (!inside || !box.width) return null;
      return { x: box.left + box.width / 2 - frame.left, y: box.top + box.height / 2 - frame.top };
    };
    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      /** Glides to `selector` and presses it; does the step even when it cannot be seen. */
      const press = async (selector: string, step: string, ms = 700) => {
        const to = spot(find(selector));
        if (to) {
          await glide(to, ms);
          glyph('hand');
          arrow.dataset.press = 'true';
          await wait(140);
          delete arrow.dataset.press;
        }
        demoStep(step);
        await wait(to ? 160 : 0);
        glyph('arrow');
      };
      for (;;) {
        await wait(1100);
        await press('[data-action="extend"]', 'extend', 900);
        await wait(1500);
        const row = spot(find('[data-row="mosaic"] [data-part="contract"]'));
        if (row) {
          await glide(row, 700);
          demoHoverRow('mosaic');
          await wait(400);
        }
        await press('[data-row="mosaic"] input', 'mosaic', 450);
        demoHoverRow(null);
        await wait(900);
        const circle = spot(find('[data-row="halden"] [data-step="0"]'));
        if (circle) {
          await glide(circle, 700);
          demoStep('tip');
          await wait(1600);
          demoStep('untip');
        }
        await press('[data-part="sort"]', 'risk', 900);
        await wait(1800);
        await press('thead input', 'all', 900);
        await wait(1300);
        await press('thead input', 'all', 300);
        await wait(1100);
        await press('[data-tab="review"]', 'review', 900);
        await wait(1700);
        await press('[data-tab="renewing"]', 'renewing', 600);
        await wait(900);
        await press('[data-part="sort"]', 'notice', 800);
        await wait(1200);
        const away = spot(find('[data-part="header"]'));
        if (away) await glide({ x: away.x, y: away.y + 30 }, 800);
        demoStep('reset');
        await wait(1200);
      }
    });
    return () => {
      stop();
      setDemoHover(null);
    };
  }, [demo, playing, root]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDown={take}
      onKeyDown={take}
      onWheel={take}
      onTouchStart={take}
    >
      <section
        ref={card}
        className={styles.card}
        data-part="card"
        aria-labelledby={`${uid}-title`}
        style={{ '--today-x': `${x(TODAY)}px` } as CSSProperties}
      >
        <header className={styles.header} data-part="header">
          <span className={styles.badge} aria-hidden="true">
            <svg viewBox="0 0 16 16" className={styles.badgeIcon}>
              <path d="M3 4.5h10M3 8h8M3 11.5h5" />
            </svg>
          </span>
          <div className={styles.heading}>
            <h3 id={`${uid}-title`} className={styles.title}>
              Contracts
            </h3>
            <p className={styles.subtitle}>
              {counts.all} active · {playbook}
            </p>
          </div>
          <button
            type="button"
            className={styles.newReview}
            data-part="new-review"
            onClick={() => {
              onNewReview?.();
              announce('New review started.');
            }}
          >
            <svg viewBox="0 0 16 16" className={styles.plus} aria-hidden="true">
              <path d="M8 2.5v11M2.5 8h11" />
            </svg>
            New review
          </button>
        </header>

        <div className={styles.toolbar}>
          <div
            ref={tablist}
            className={styles.tabs}
            role="tablist"
            aria-label="Contracts by status"
            data-part="tabs"
            onKeyDown={(event) => {
              const index = TABS.findIndex((item) => item.id === tab);
              const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
              if (!step) return;
              event.preventDefault();
              const next = TABS[(index + step + TABS.length) % TABS.length];
              chooseTab(next.id);
              tablist.current?.querySelector<HTMLElement>(`[data-tab="${next.id}"]`)?.focus();
            }}
          >
            <span ref={indicator} className={styles.indicator} aria-hidden="true" />
            {TABS.map((item) => (
              <button
                key={item.id}
                type="button"
                role="tab"
                id={`${uid}-tab-${item.id}`}
                aria-selected={tab === item.id}
                aria-controls={`${uid}-panel`}
                tabIndex={tab === item.id ? 0 : -1}
                className={styles.tab}
                data-tab={item.id}
                onClick={() => chooseTab(item.id)}
              >
                {item.label}
                <span className={styles.count}>{counts[item.id]}</span>
              </button>
            ))}
          </div>
          <button
            type="button"
            className={styles.sortBy}
            data-part="sort"
            onClick={() => chooseSort(sort === 'notice' ? 'risk' : 'notice')}
            aria-label={`Sorted by ${sort === 'notice' ? 'notice date' : 'risk'}. Sort by ${sort === 'notice' ? 'risk' : 'notice date'} instead`}
          >
            Sorted by {sort === 'notice' ? 'notice date' : 'risk'}
          </button>
        </div>

        <div className={styles.frame} data-part="table">
          <div
            ref={scroller}
            className={styles.scroller}
            id={`${uid}-panel`}
            role="tabpanel"
            aria-labelledby={`${uid}-tab-${tab}`}
            onKeyDown={onTableKey}
            onScroll={() => setTip(null)}
          >
            <table className={styles.table}>
              <colgroup>
                <col className={styles.colCheck} />
                <col className={styles.colContract} />
                <col className={styles.colTimeline} />
                <col className={styles.colNotice} />
                <col className={styles.colApprovals} />
                <col className={styles.colRisk} />
              </colgroup>
              <thead>
                <tr>
                  <th className={styles.checkCell} scope="col">
                    <Check
                      checked={allChosen}
                      mixed={someChosen}
                      label="Select all contracts in this tab"
                      onChange={toggleAll}
                    />
                  </th>
                  <th scope="col" className={styles.lead}>
                    Contract
                  </th>
                  <th scope="col" className={styles.months} data-part="months">
                    <span className={styles.visuallyHidden}>Timeline, October to March</span>
                    <span className={styles.todayHead} aria-hidden="true" />
                    {TICKS.map((tick) => (
                      <span
                        key={tick.label}
                        className={styles.month}
                        style={{ '--x': `${x(tick.day)}px` } as CSSProperties}
                        aria-hidden="true"
                      >
                        {tick.label}
                      </span>
                    ))}
                  </th>
                  <th
                    scope="col"
                    className={styles.noticeHead}
                    aria-sort={sort === 'notice' ? 'ascending' : undefined}
                  >
                    <button
                      type="button"
                      className={styles.headSort}
                      data-active={sort === 'notice'}
                      onClick={() => chooseSort('notice')}
                    >
                      Notice
                    </button>
                  </th>
                  <th scope="col" className={styles.divided}>
                    Approvals
                  </th>
                  <th
                    scope="col"
                    className={styles.divided}
                    aria-sort={sort === 'risk' ? 'descending' : undefined}
                  >
                    <button
                      type="button"
                      className={styles.headSort}
                      data-active={sort === 'risk'}
                      onClick={() => chooseSort('risk')}
                    >
                      Risk
                    </button>
                  </th>
                </tr>
              </thead>
              <tbody key={tab}>
                {visible.map((contract, index) => (
                  <Row
                    key={contract.id}
                    contract={contract}
                    index={index}
                    selected={selected.has(contract.id)}
                    hovered={demoHover === contract.id}
                    onToggle={() => toggle(contract.id)}
                    onTip={showTip}
                    onUntip={() => setTip(null)}
                    rowRef={(row) => {
                      if (row) rows.current.set(contract.id, row);
                      else rows.current.delete(contract.id);
                    }}
                  />
                ))}
              </tbody>
            </table>
          </div>
          <div className={styles.fade} aria-hidden="true" />

          <div
            className={styles.actions}
            role="toolbar"
            aria-label="Selected contracts"
            data-part="action-bar"
            data-open={chosen.length > 0}
            inert={chosen.length === 0}
          >
            <span className={styles.selectedCount}>
              <span key={lastCount.current} className={styles.number}>
                {lastCount.current}
              </span>{' '}
              selected
            </span>
            <span className={styles.rule} aria-hidden="true" />
            <button type="button" className={styles.action} data-action="remind" onClick={remind}>
              {reminded ? 'Reminder sent' : 'Remind owner'}
            </button>
            <button type="button" className={styles.action} data-action="extend" onClick={extend}>
              Extend notice
            </button>
            <button
              type="button"
              className={`${styles.action} ${styles.primary}`}
              data-action="open"
              onClick={open}
            >
              Open review
            </button>
          </div>
        </div>

        {tip && (
          <div
            className={styles.tip}
            data-part="tooltip"
            role="tooltip"
            style={{ '--x': `${tip.x}px`, '--y': `${tip.y}px` } as CSSProperties}
          >
            {tip.lines.map((line) => (
              <span key={line}>{line}</span>
            ))}
          </div>
        )}
        <p className={styles.visuallyHidden} aria-live="polite">
          {message}
        </p>
      </section>

      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
    </div>
  );
}

function Row({
  contract,
  index,
  selected,
  hovered,
  onToggle,
  onTip,
  onUntip,
  rowRef,
}: {
  contract: Contract;
  index: number;
  selected: boolean;
  hovered: boolean;
  onToggle: () => void;
  onTip: (target: HTMLElement, lines: string[]) => void;
  onUntip: () => void;
  rowRef: (row: HTMLTableRowElement | null) => void;
}) {
  const soon = contract.notice - TODAY <= SOON;
  const from = x(contract.notice);
  const to = x(contract.renews);
  const level = riskLevel(contract.risk);
  const lit = Math.max(1, Math.round(contract.risk / 20));
  const summary = contract.approvals
    .map((state, step) => `${STEPS[step]} ${STATE_WORDS[state].toLowerCase()}`)
    .join(', ');
  return (
    <tr
      ref={rowRef}
      className={styles.row}
      data-row={contract.id}
      data-selected={selected}
      data-hover={hovered || undefined}
      style={{ '--i': Math.min(index, 8) } as CSSProperties}
      onClick={(event) => {
        // A click anywhere on the row selects it, except on its own controls.
        if ((event.target as HTMLElement).closest('input, button, [tabindex]')) return;
        onToggle();
      }}
    >
      <td className={styles.checkCell}>
        <Check checked={selected} label={`Select ${contract.name}`} onChange={onToggle} />
      </td>
      <th scope="row" className={styles.lead} data-part="contract">
        <span className={styles.name}>{contract.name}</span>
        <span className={styles.meta}>
          {contract.type} · <span className={styles.money}>${contract.value}k</span>
        </span>
      </th>
      <td className={styles.timelineCell}>
        <span
          className={styles.track}
          data-part="timeline"
          data-soon={soon}
          style={
            {
              '--from': `${from}px`,
              '--to': `${to}px`,
              '--window': `${Math.max(to - from, 0)}px`,
            } as CSSProperties
          }
          role="img"
          aria-label={`Notice by ${dayLabel(contract.notice)}, renews ${dayLabel(contract.renews)}`}
        >
          <span className={styles.rail} />
          <span className={styles.elapsed} />
          <span className={styles.window} />
          {contract.renews <= SPAN && <span className={styles.tick} />}
        </span>
      </td>
      <td className={styles.notice} data-soon={soon}>
        {dayLabel(contract.notice)}
      </td>
      <td className={styles.divided}>
        <span
          className={styles.approvals}
          tabIndex={0}
          role="img"
          aria-label={`Approvals: ${summary}`}
          data-part="approvals"
          onFocus={(event) =>
            onTip(
              event.currentTarget,
              contract.approvals.map((state, step) => `${STEPS[step]} · ${STATE_WORDS[state]}`),
            )
          }
          onBlur={onUntip}
          onPointerLeave={onUntip}
        >
          {contract.approvals.map((state, step) => (
            <span
              key={step}
              className={styles.step}
              data-state={state}
              data-step={step}
              onPointerEnter={(event) =>
                onTip(event.currentTarget, [`${STEPS[step]} · ${STATE_WORDS[state]}`])
              }
            >
              <StepGlyph state={state} />
            </span>
          ))}
        </span>
      </td>
      <td className={styles.divided}>
        <span className={styles.risk} data-level={level} data-part="risk">
          <span className={styles.riskNumber}>{contract.risk}</span>
          <span className={styles.bars} aria-hidden="true">
            {[0, 1, 2, 3, 4].map((bar) => (
              <span key={bar} className={styles.bar} data-lit={bar < lit} />
            ))}
          </span>
        </span>
      </td>
    </tr>
  );
}

/** A checkbox: the real input, invisible, over the drawn box. */
function Check({
  checked,
  mixed = false,
  label,
  onChange,
}: {
  checked: boolean;
  mixed?: boolean;
  label: string;
  onChange: () => void;
}) {
  const input = useRef<HTMLInputElement>(null);
  useEffect(() => {
    if (input.current) input.current.indeterminate = mixed;
  }, [mixed]);
  return (
    <span className={styles.check} data-part="checkbox">
      <input
        ref={input}
        type="checkbox"
        className={styles.checkInput}
        checked={checked}
        aria-label={label}
        onChange={onChange}
      />
      <span className={styles.box} data-checked={checked} data-mixed={mixed} aria-hidden="true">
        <svg viewBox="0 0 16 16" className={styles.checkMark}>
          {mixed ? <path d="M4.5 8h7" /> : <path d="M4.2 8.3l2.5 2.4 5-5.2" />}
        </svg>
      </span>
    </span>
  );
}

/** The mark inside an approval's circle; a waiting one is an empty dashed ring. */
function StepGlyph({ state }: { state: Approval }) {
  if (state === 'waiting') return null;
  return (
    <svg viewBox="0 0 18 18" className={styles.stepGlyph} aria-hidden="true">
      {state === 'done' && <path d="M5.6 9.3l2.3 2.2 4.5-4.8" />}
      {state === 'rejected' && <path d="M6.6 6.6l4.8 4.8M11.4 6.6l-4.8 4.8" />}
      {state === 'active' &&
        [0, 1, 2, 3, 4, 5, 6, 7].map((spoke) => (
          <path
            key={spoke}
            d="M9 4.6v2.2"
            transform={`rotate(${spoke * 45} 9 9)`}
            style={{ opacity: 0.35 + (spoke / 7) * 0.65 }}
          />
        ))}
    </svg>
  );
}
```

### contracts-table/contracts-table.module.css

```css
/* Contracts table (contracts-table.tsx): a card on a quiet stage, near-black at night and white
   by day. The card is 582px wide at most; under that it takes the stage's width and the table
   scrolls sideways inside its frame, so the page never does.

   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).

   Motion is transforms and opacity: the action bar slides, rows glide when re-sorted (the Web
   Animations API) and fade in on a new tab, the notice window slides along its track. The tab
   pill also eases its width. Under reduced motion, all of it simply changes. */

.root {
  /* The stage, the card on it, the table's frame and its header. */
  --stage: #f1f1f2;
  --card: #fff;
  --card-edge: rgb(0 0 0 / 0.08);
  --frame: #fbfbfb;
  --head: #fff;
  --line: #ececee;
  --row-hover: #f5f5f7;
  --row-selected: #f1f0ff;
  /* Words: strong, muted, faint. */
  --ink: #141416;
  --muted: #6c6c72;
  --faint: #a3a3a9;
  /* The tabs. */
  --tabs: #f4f4f5;
  --tabs-edge: #e6e6e8;
  --tab-on: #fff;
  --tab-on-edge: rgb(0 0 0 / 0.08);
  --tab-on-shadow: rgb(0 0 0 / 0.08);
  /* The round badge by the title. */
  --badge: #f0f0f1;
  --badge-ink: #66666c;
  /* Buttons: the light primary and the quiet ones in the action bar. */
  --primary-top: #2b2b2f;
  --primary-bottom: #0f0f11;
  --primary-ink: #fff;
  --primary-edge: rgb(255 255 255 / 0.14);
  --bar: #fff;
  --bar-edge: rgb(0 0 0 / 0.1);
  --bar-shadow: rgb(20 20 30 / 0.14);
  --button: #f4f4f5;
  --button-edge: rgb(0 0 0 / 0.08);
  --button-hover: #ebebed;
  /* The checkbox. */
  --box-edge: #cfcfd4;
  --accent: #6464f2;
  --accent-ink: #fff;
  /* The timeline. */
  --today: #6d6df5;
  --rail: #e4e4e7;
  --elapsed: #c4c4c9;
  --hatch: #a5a5ab;
  --hatch-bg: #ececef;
  --tick: #8e8e95;
  --soon: #c77a06;
  --soon-hatch: #e19a2b;
  --soon-bg: #fbe9cc;
  /* Approvals. */
  --done-bg: #ddf5e8;
  --done: #1d9a5a;
  --active-bg: #e7e6ff;
  --active: #5b55f0;
  --rejected-bg: #fde6e7;
  --rejected: #df3b45;
  --waiting: #c3c3c8;
  /* Risk. */
  --risk-low: #8a8a90;
  --risk-off: #e4e4e7;
  --risk-medium: #e0951f;
  --risk-high: #e0424b;
  /* The tooltip. */
  --tip: #1b1b1e;
  --tip-ink: #f4f4f5;
  /* The same on both schemes: the focus ring, and the demo's pointer. */
  --focus: #7c7cff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.3);
  --sans: Satoshi, Inter, ui-sans-serif, system-ui, sans-serif;
  --mono: JetBrains, ui-monospace, 'SF Mono', Menlo, monospace;
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  place-items: center;
  width: 100%;
  min-height: 600px;
  padding: 40px 16px;
  box-sizing: border-box;
  overflow: hidden;
  overflow: clip;
  background: var(--stage);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f1f1f2, #1e1e1e);
    --card: light-dark(#fff, #111);
    --card-edge: light-dark(rgb(0 0 0 / 0.08), #202020);
    --frame: light-dark(#fbfbfb, #0d0d0d);
    --head: light-dark(#fff, #111);
    --line: light-dark(#ececee, #1c1c1c);
    --row-hover: light-dark(#f5f5f7, #141416);
    --row-selected: light-dark(#f1f0ff, #1a191e);
    --ink: light-dark(#141416, #fafafa);
    --muted: light-dark(#6c6c72, #979797);
    --faint: light-dark(#a3a3a9, #626262);
    --tabs: light-dark(#f4f4f5, #181818);
    --tabs-edge: light-dark(#e6e6e8, #242424);
    --tab-on: light-dark(#fff, #363636);
    --tab-on-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.07));
    --tab-on-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.25));
    --badge: light-dark(#f0f0f1, #2d2d2d);
    --badge-ink: light-dark(#66666c, #cbcbcb);
    --primary-top: light-dark(#2b2b2f, #adadad);
    --primary-bottom: light-dark(#0f0f11, #fefefe);
    --primary-ink: light-dark(#fff, #111);
    --primary-edge: light-dark(rgb(255 255 255 / 0.14), #c6c6c6);
    --bar: light-dark(#fff, #1e1e1e);
    --bar-edge: light-dark(rgb(0 0 0 / 0.1), #3a3a3a);
    --bar-shadow: light-dark(rgb(20 20 30 / 0.14), rgb(0 0 0 / 0.6));
    --button: light-dark(#f4f4f5, #262626);
    --button-edge: light-dark(rgb(0 0 0 / 0.08), #383838);
    --button-hover: light-dark(#ebebed, #2e2e2e);
    --box-edge: light-dark(#cfcfd4, #343434);
    --accent: light-dark(#6464f2, #7470ff);
    --today: light-dark(#6d6df5, #7172f8);
    --rail: light-dark(#e4e4e7, #1f1f1f);
    --elapsed: light-dark(#c4c4c9, #525252);
    --hatch: light-dark(#a5a5ab, #6a6a6a);
    --hatch-bg: light-dark(#ececef, #2d2d2d);
    --tick: light-dark(#8e8e95, #898989);
    --soon: light-dark(#c77a06, #f5a524);
    --soon-hatch: light-dark(#e19a2b, #c08a3c);
    --soon-bg: light-dark(#fbe9cc, #4a3520);
    --done-bg: light-dark(#ddf5e8, #0d2919);
    --done: light-dark(#1d9a5a, #3edb8b);
    --active-bg: light-dark(#e7e6ff, #1e1f4c);
    --active: light-dark(#5b55f0, #a59dff);
    --rejected-bg: light-dark(#fde6e7, #2a1517);
    --rejected: light-dark(#df3b45, #fa5761);
    --waiting: light-dark(#c3c3c8, #464646);
    --risk-low: light-dark(#8a8a90, #a2a2a2);
    --risk-off: light-dark(#e4e4e7, #2a2a2a);
    --risk-medium: light-dark(#e0951f, #f0ac45);
    --risk-high: light-dark(#e0424b, #f25d63);
    --tip: light-dark(#1b1b1e, #f4f4f5);
    --tip-ink: light-dark(#f4f4f5, #141416);
  }
}
/* 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;
}

:where(.root button) {
  font: inherit;
  color: inherit;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* ——— The card ——— */

.card {
  position: relative;
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  width: min(582px, 100%);
  box-sizing: border-box;
  padding: 11px 11px 11px;
  border: 1px solid var(--card-edge);
  border-radius: 14px;
  background: var(--card);
}

.header {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 33px;
}
.badge {
  display: grid;
  flex: none;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--badge);
}
.badgeIcon {
  width: 13px;
  height: 13px;
  fill: none;
  stroke: var(--badge-ink);
  stroke-width: 1.9;
  stroke-linecap: round;
}
.heading {
  flex: 1;
  min-width: 0;
}
.title {
  margin: 0;
  font-size: 14.5px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: -0.005em;
}
.subtitle {
  margin: 0;
  overflow: hidden;
  color: var(--muted);
  font-size: 13px;
  line-height: 17px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.newReview {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 6px;
  height: 32px;
  padding: 0 13px 0 11px;
  border: 0;
  border-radius: 16px;
  background: linear-gradient(var(--primary-top), var(--primary-bottom));
  box-shadow: inset 0 1px 0 var(--primary-edge);
  color: var(--primary-ink);
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: transform 0.15s ease;
}
.newReview:active {
  transform: scale(0.97);
}
.plus {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}

/* ——— Tabs and sort ——— */

.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px 12px;
  margin-top: 13px;
}
.tabs {
  position: relative;
  display: flex;
  max-width: 100%;
  box-sizing: border-box;
  padding: 2px;
  overflow-x: auto;
  border: 1px solid var(--tabs-edge);
  border-radius: 16px;
  background: var(--tabs);
  scrollbar-width: none;
}
.indicator {
  position: absolute;
  top: 2px;
  left: 0;
  height: 26px;
  border-radius: 13px;
  background: var(--tab-on);
  box-shadow:
    inset 0 1px 0 var(--tab-on-edge),
    0 1px 2px var(--tab-on-shadow);
  transition:
    transform 0.32s cubic-bezier(0.3, 0.8, 0.2, 1),
    width 0.32s cubic-bezier(0.3, 0.8, 0.2, 1);
}
.tab {
  position: relative;
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 8px;
  height: 26px;
  padding: 0 13px;
  border: 0;
  border-radius: 13px;
  background: none;
  color: var(--muted);
  font-size: 13px;
  white-space: nowrap;
  cursor: pointer;
  transition: color 0.2s ease;
}
.tab:hover,
.tab[aria-selected='true'] {
  color: var(--ink);
}
.tab:focus-visible {
  outline-offset: -2px;
}
.count {
  color: var(--faint);
  font-family: var(--mono);
  font-size: 12.5px;
}
.tab[aria-selected='true'] .count {
  color: var(--muted);
}
.sortBy {
  margin-left: auto;
  padding: 0;
  border: 0;
  background: none;
  color: var(--faint);
  font-size: 12.75px;
  cursor: pointer;
  transition: color 0.2s ease;
}
.sortBy:hover {
  color: var(--muted);
}

/* ——— The table ——— */

.frame {
  position: relative;
  height: 399px;
  margin-top: 12px;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--frame);
}
.scroller {
  height: 100%;
  overflow: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
}
.scroller::-webkit-scrollbar {
  display: none;
}
.table {
  width: 100%;
  min-width: 558px;
  border-collapse: separate;
  border-spacing: 0;
  table-layout: fixed;
  /* Room under the last row, so it can scroll out from under the action bar. */
  margin-bottom: 60px;
}
.colCheck {
  width: 40px;
}
.colContract {
  width: 144px;
}
.colTimeline {
  width: 146px;
}
.colNotice {
  width: 54px;
}
.colApprovals {
  width: 104px;
}
.colRisk {
  width: 70px;
}

.table :where(th, td) {
  padding: 0;
  border-bottom: 1px solid var(--line);
  text-align: left;
  vertical-align: middle;
}
.table thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  height: 35px;
  background: var(--head);
  color: var(--muted);
  font-size: 13px;
  font-weight: 400;
}
.checkCell {
  padding-left: 15px;
  border-right: 1px solid var(--line);
}
.lead {
  padding-left: 11px;
  border-right: 1px solid var(--line);
}
.divided {
  padding-left: 11px;
  border-left: 1px solid var(--line);
}
.headSort {
  padding: 0;
  border: 0;
  background: none;
  color: var(--muted);
  font-size: 13px;
  cursor: pointer;
}
.headSort:hover {
  color: var(--ink);
}
.noticeHead {
  padding-right: 13px;
  text-align: right;
}

/* The months, over the timeline, and the top of today's line. */
.months {
  position: relative;
  padding-left: 11px;
}
.month {
  position: absolute;
  top: 50%;
  left: calc(11px + var(--x));
  color: var(--faint);
  font-family: var(--mono);
  font-size: 12px;
  transform: translate(-50%, -50%);
}
.todayHead {
  position: absolute;
  top: 0;
  bottom: -1px;
  left: calc(11px + var(--today-x));
  width: 1px;
  background: repeating-linear-gradient(var(--today) 0 3px, transparent 3px 6px);
}
.todayHead::after {
  content: '';
  position: absolute;
  bottom: -3px;
  left: -2px;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--today);
}

.row {
  position: relative;
  height: 52px;
  cursor: pointer;
  transition: background-color 0.15s ease;
  animation: rowIn 0.36s cubic-bezier(0.2, 0.8, 0.2, 1) both;
  animation-delay: calc(var(--i) * 28ms);
}
@keyframes rowIn {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
}
.row:hover,
.row[data-hover] {
  background: var(--row-hover);
}
.row[data-selected='true'] {
  background: var(--row-selected);
}
.row th {
  font-weight: 400;
}
.name {
  display: block;
  overflow: hidden;
  color: var(--ink);
  font-size: 14.75px;
  font-weight: 500;
  line-height: 18px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.meta {
  display: block;
  overflow: hidden;
  color: var(--muted);
  font-size: 13.5px;
  line-height: 17px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.money {
  font-variant-numeric: tabular-nums;
}

/* The checkbox: the input, invisible, over the drawn box. */
.check {
  position: relative;
  display: block;
  width: 16px;
  height: 16px;
}
.checkInput {
  position: absolute;
  inset: -6px;
  width: 28px;
  height: 28px;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
.box {
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  box-sizing: border-box;
  border: 1.5px solid var(--box-edge);
  border-radius: 4.5px;
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}
.checkMark {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: var(--accent-ink);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity 0.15s ease,
    transform 0.2s cubic-bezier(0.3, 1.4, 0.5, 1);
}
.box[data-checked='true'],
.box[data-mixed='true'] {
  border-color: var(--accent);
  background: var(--accent);
}
.box[data-checked='true'] .checkMark,
.box[data-mixed='true'] .checkMark {
  opacity: 1;
  transform: none;
}
.checkInput:focus-visible + .box {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The timeline: rail, elapsed bar to the notice date, hatched window, tick on renewal. */
.timelineCell {
  position: relative;
  padding-left: 11px;
}
.timelineCell::before {
  /* Today, through every row. */
  content: '';
  position: absolute;
  top: 0;
  bottom: -1px;
  left: calc(11px + var(--today-x));
  width: 1px;
  background: repeating-linear-gradient(var(--today) 0 3px, transparent 3px 6px);
  background-attachment: local;
}
.track {
  position: relative;
  display: block;
  width: 124px;
  height: 14px;
}
.rail {
  position: absolute;
  top: 6.5px;
  left: 0;
  right: 0;
  height: 1px;
  background: var(--rail);
}
.elapsed {
  position: absolute;
  top: 5px;
  left: 0;
  width: var(--from);
  height: 4px;
  border-radius: 2px;
  background: var(--elapsed);
  transition: width 0.6s cubic-bezier(0.3, 0.8, 0.2, 1);
}
.window {
  position: absolute;
  top: 3px;
  left: 0;
  width: var(--window);
  height: 8px;
  box-sizing: border-box;
  border-radius: 2px;
  background: repeating-linear-gradient(-45deg, var(--hatch) 0 1.1px, var(--hatch-bg) 1.1px 2.25px);
  transform: translateX(var(--from));
  transition: transform 0.6s cubic-bezier(0.3, 0.8, 0.2, 1);
}
.tick {
  position: absolute;
  top: 0;
  left: -1px;
  width: 2px;
  height: 14px;
  border-radius: 1px;
  background: var(--tick);
  transform: translateX(var(--to));
  transition: transform 0.6s cubic-bezier(0.3, 0.8, 0.2, 1);
}
.track[data-soon='true'] .window {
  background: repeating-linear-gradient(
    -45deg,
    var(--soon-hatch) 0 1.1px,
    var(--soon-bg) 1.1px 2.25px
  );
}
.track[data-soon='true'] .tick {
  background: var(--soon);
}
.notice {
  padding-right: 13px;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 12.75px;
  text-align: right;
  white-space: nowrap;
  transition: color 0.3s ease;
}
.notice[data-soon='true'] {
  color: var(--soon);
}

/* Approvals: four circles. */
.approvals {
  display: flex;
  gap: 4px;
  width: max-content;
  border-radius: 10px;
}
.step {
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  box-sizing: border-box;
  border-radius: 50%;
}
.step[data-state='done'] {
  background: var(--done-bg);
  color: var(--done);
}
.step[data-state='active'] {
  background: var(--active-bg);
  color: var(--active);
}
.step[data-state='rejected'] {
  background: var(--rejected-bg);
  color: var(--rejected);
}
.step[data-state='waiting'] {
  border: 1.5px dashed var(--waiting);
}
.stepGlyph {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.step[data-state='active'] .stepGlyph {
  stroke-width: 1.5;
  animation: spin 1s steps(8) infinite;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* Risk: a number and five bars. */
.risk {
  display: flex;
  align-items: center;
  gap: 9px;
}
.riskNumber {
  min-width: 17px;
  color: var(--ink);
  font-family: var(--mono);
  font-size: 15px;
}
.risk[data-level='high'] .riskNumber {
  color: var(--risk-high);
}
.bars {
  display: flex;
  gap: 2px;
}
.bar {
  width: 3px;
  height: 12px;
  border-radius: 1.5px;
  background: var(--risk-off);
}
.risk[data-level='low'] .bar[data-lit='true'] {
  background: var(--risk-low);
}
.risk[data-level='medium'] .bar[data-lit='true'] {
  background: var(--risk-medium);
}
.risk[data-level='high'] .bar[data-lit='true'] {
  background: var(--risk-high);
}

/* The bottom of the list fades out, under the action bar. */
.fade {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 3;
  height: 152px;
  background: linear-gradient(transparent, var(--frame) 88%);
  pointer-events: none;
}

/* ——— The action bar ——— */

.actions {
  position: absolute;
  bottom: 9px;
  left: calc(50% - 5px);
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: calc(100% - 16px);
  box-sizing: border-box;
  padding: 5px 5px 5px 16px;
  overflow-x: auto;
  border: 1px solid var(--bar-edge);
  border-radius: 23px;
  background: var(--bar);
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.05),
    0 8px 24px var(--bar-shadow);
  opacity: 0;
  transform: translate(-50%, calc(100% + 12px));
  scrollbar-width: none;
  transition:
    transform 0.38s cubic-bezier(0.3, 0.8, 0.2, 1),
    opacity 0.25s ease;
}
.actions[data-open='true'] {
  opacity: 1;
  transform: translate(-50%, 0);
}
.selectedCount {
  flex: none;
  color: var(--muted);
  font-size: 14.75px;
  white-space: nowrap;
}
.number {
  display: inline-block;
  color: var(--ink);
  font-weight: 500;
  animation: countIn 0.26s cubic-bezier(0.3, 0.8, 0.2, 1);
}
@keyframes countIn {
  from {
    opacity: 0;
    transform: translateY(5px);
  }
}
.rule {
  flex: none;
  width: 1px;
  height: 16px;
  margin: 0 1px 0 3px;
  background: var(--button-edge);
}
.action {
  flex: none;
  height: 32px;
  padding: 0 13px;
  border: 1px solid var(--button-edge);
  border-radius: 16px;
  background: var(--button);
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    transform 0.15s ease;
}
.action:hover {
  background: var(--button-hover);
}
.action:active {
  transform: scale(0.97);
}
.action:focus-visible {
  outline-offset: -1px;
}
.primary {
  border: 0;
  background: linear-gradient(var(--primary-top), var(--primary-bottom));
  box-shadow: inset 0 1px 0 var(--primary-edge);
  color: var(--primary-ink);
}
.primary:hover {
  background: linear-gradient(var(--primary-top), var(--primary-bottom));
  filter: brightness(1.05);
}

/* ——— Tooltip ——— */

.tip {
  position: absolute;
  top: var(--y);
  left: var(--x);
  z-index: 6;
  display: grid;
  gap: 2px;
  padding: 6px 9px;
  border-radius: 8px;
  background: var(--tip);
  color: var(--tip-ink);
  font-size: 12px;
  line-height: 15px;
  white-space: nowrap;
  pointer-events: none;
  transform: translate(-50%, calc(-100% - 8px));
  animation: tipIn 0.16s ease both;
}
@keyframes tipIn {
  from {
    opacity: 0;
    transform: translate(-50%, calc(-100% - 4px));
  }
}

/* ——— Demo pointer ——— */

.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 10;
  pointer-events: none;
}
.pointer {
  width: 20px;
  height: 20px;
}
.pointer .arrow,
.pointer .hand {
  transition: transform 0.12s ease;
}
.pointer[data-press] .arrow,
.pointer[data-press] .hand {
  transform: scale(0.84);
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

.visuallyHidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Narrow cards: the subtitle wraps, the action bar spans the frame and scrolls sideways. */
@container (max-width: 500px) {
  .subtitle {
    white-space: normal;
  }
  .actions {
    left: 8px;
    right: 8px;
    transform: translateY(calc(100% + 12px));
  }
  .actions[data-open='true'] {
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .row,
  .number,
  .tip,
  .step[data-state='active'] .stepGlyph {
    animation: none;
  }
  .indicator,
  .elapsed,
  .window,
  .tick,
  .actions {
    transition: none;
  }
}
```

### contracts-table/data.ts

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

/*
 * The contracts the table shows: invented companies, plausible terms. Dates are days counted from
 * the timeline's first day (October 1), so the drawing needs no calendar maths; `dayLabel` turns
 * them back into "Oct 31". Nine contracts are up for renewal and four are in review, written out
 * by hand; the 115 signed ones are made from two word lists by a fixed recipe, so every render
 * draws the same table.
 */

/** Where one sign-off stands. */
export type Approval = 'done' | 'active' | 'rejected' | 'waiting';

export type Status = 'renewing' | 'review' | 'signed';

export type Contract = {
  id: string;
  name: string;
  /** The kind of paper: MSA, DPA, Order form… */
  type: string;
  /** Yearly value, in thousands of dollars. */
  value: number;
  status: Status;
  /** The last day to give notice, in days from October 1. */
  notice: number;
  /** The day the contract renews on its own, in days from October 1. */
  renews: number;
  /** Legal, Finance, Security, Owner. */
  approvals: [Approval, Approval, Approval, Approval];
  /** 0–100. */
  risk: number;
  /** Who owns the contract on our side. */
  owner: string;
};

/** The four sign-offs, in the order their circles are drawn. */
export const STEPS = ['Legal', 'Finance', 'Security', 'Owner'] as const;

/** Day 0 of the timeline. The year is not drawn anywhere. */
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const START = Date.UTC(2026, 9, 1);
const DAY = 86_400_000;

/** "Oct 31" for a day counted from October 1. */
export function dayLabel(day: number) {
  const date = new Date(START + day * DAY);
  return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
}

/** The day count of a calendar date (month 1–12) in the timeline's six months and after. */
export function dayOf(month: number, date: number) {
  const year = month >= 10 ? 2026 : 2027;
  return Math.round((Date.UTC(year, month - 1, date) - START) / DAY);
}

/** Today: October 15. */
export const TODAY = dayOf(10, 15);
/** The timeline's span, in days: October 1 to April 1. */
export const SPAN = dayOf(4, 1);
/** The months labelled above the timeline. */
export const TICKS = [
  { label: 'Nov', day: dayOf(11, 1) },
  { label: 'Jan', day: dayOf(1, 1) },
  { label: 'Mar', day: dayOf(3, 1) },
];

const D = 'done';
const A = 'active';
const R = 'rejected';
const W = 'waiting';

const make = (
  id: string,
  name: string,
  type: string,
  value: number,
  status: Status,
  notice: [number, number],
  renews: [number, number],
  approvals: Contract['approvals'],
  risk: number,
  owner: string,
): Contract => ({
  id,
  name,
  type,
  value,
  status,
  notice: dayOf(...notice),
  renews: dayOf(...renews),
  approvals,
  risk,
  owner,
});

const HANDMADE: Contract[] = [
  make(
    'kestrel',
    'Kestrel Cloud',
    'Order form',
    84,
    'renewing',
    [10, 31],
    [12, 30],
    [D, D, D, W],
    24,
    'Ana',
  ),
  make('halden', 'Halden Works', 'MSA', 240, 'renewing', [11, 1], [1, 30], [A, D, R, W], 78, 'Rui'),
  make('mosaic', 'Mosaic Data', 'DPA', 36, 'renewing', [12, 1], [1, 15], [D, D, W, W], 41, 'Lea'),
  make(
    'quill',
    'Quill & Co',
    'Services',
    18,
    'renewing',
    [12, 30],
    [3, 1],
    [W, W, W, W],
    55,
    'Ana',
  ),
  make(
    'arbor',
    'Arbor Labs',
    'Reseller',
    120,
    'renewing',
    [12, 31],
    [4, 1],
    [D, W, W, W],
    33,
    'Tom',
  ),
  make(
    'fernway',
    'Fernway Health',
    'SaaS',
    56,
    'renewing',
    [1, 1],
    [1, 31],
    [D, D, D, D],
    12,
    'Lea',
  ),
  make(
    'tidewell',
    'Tidewell Studio',
    'Licence',
    9,
    'renewing',
    [1, 20],
    [3, 20],
    [D, D, A, W],
    29,
    'Rui',
  ),
  make(
    'brightline',
    'Brightline Freight',
    'Logistics',
    64,
    'renewing',
    [2, 2],
    [3, 3],
    [D, D, D, D],
    18,
    'Tom',
  ),
  make(
    'copperleaf',
    'Copperleaf Media',
    'Licence',
    27,
    'renewing',
    [2, 15],
    [3, 31],
    [D, R, W, W],
    62,
    'Ana',
  ),
  make('lumen', 'Lumenfield', 'MSA', 310, 'review', [3, 2], [4, 1], [A, W, W, W], 47, 'Rui'),
  make(
    'saltmarsh',
    'Saltmarsh Bio',
    'DPA',
    22,
    'review',
    [2, 20],
    [3, 22],
    [D, A, W, W],
    36,
    'Lea',
  ),
  make(
    'orchid',
    'Orchid Analytics',
    'Order form',
    48,
    'review',
    [1, 28],
    [3, 14],
    [D, D, A, W],
    21,
    'Tom',
  ),
  make(
    'pebble',
    'Pebble Payroll',
    'Services',
    15,
    'review',
    [2, 9],
    [3, 11],
    [R, D, W, W],
    71,
    'Ana',
  ),
];

const FIRST = [
  'Alder',
  'Basalt',
  'Cobalt',
  'Dunmore',
  'Ember',
  'Fjordline',
  'Garnet',
  'Heron',
  'Indigo',
  'Juniper',
  'Kelpie',
  'Lichen',
  'Marlow',
  'Nettle',
  'Onyxia',
  'Pikeway',
  'Quarry',
  'Rowan',
  'Sorrel',
  'Thistle',
  'Umber',
  'Valemont',
  'Willow',
];
const SECOND = ['Labs', 'Supply', 'Studio', 'Partners', 'Analytics'];
const TYPES = ['Order form', 'MSA', 'DPA', 'SaaS', 'Services', 'Licence'];
const OWNERS = ['Ana', 'Rui', 'Lea', 'Tom'];

/** 115 signed contracts, renewing from late January on: quiet, mostly low risk. */
const SIGNED: Contract[] = Array.from({ length: 115 }, (_, i) => {
  // A fixed shuffle: steps through the lists at strides that never line up.
  const notice = 100 + ((i * 37) % 160);
  return {
    id: `signed-${i}`,
    name: `${FIRST[(i * 7) % FIRST.length]} ${SECOND[(i * 3 + Math.floor(i / FIRST.length)) % SECOND.length]}`,
    type: TYPES[(i * 5) % TYPES.length],
    value: 6 + ((i * 53) % 190),
    status: 'signed',
    notice,
    renews: notice + 30 + ((i * 11) % 3) * 30,
    approvals: [D, D, D, D],
    risk: 4 + ((i * 29) % 44),
    owner: OWNERS[i % OWNERS.length],
  };
});

export const CONTRACTS: Contract[] = [...HANDMADE, ...SIGNED];
```

### contracts-table/demo-pointer.tsx

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

'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, or
 * glide the pointer somewhere. 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.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it. The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

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

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** 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;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  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.8, y: stage.offsetHeight * 0.9 };

  /** 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);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    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.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        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,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

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

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

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (contracts-table.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over a control. It
 * is decorative: what it does shows on the table itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <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"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### contracts-table/use-autoplay.ts

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

'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 };
}
```