# Adaptive list

> A grouped list that lays itself out for the room it has: widen its panel and the amounts slide into columns.

A list of deals that watches its own width. Narrow, each deal reads as a line. Wider, the amount moves into a column of its own and spells its currency, and the dates line up. Wider still, the contacts join in. Between layouts every cell glides from its old place to the new one, row after row.

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

Category: Data.

## Anatomy

1. **Title bar**: The list’s icon, its heading and the number of deals shown, which drops when the filter hides some.
2. **Filter**: Shows the open deals only: the won stage folds away and the total updates. Pressed, it keeps its grey.
3. **Stage header**: The stage’s status ring, name and count. A button: it folds and unfolds the stage’s rows in 340 ms.
4. **Column icons**: From the columns layout on, each header shows what the columns below it hold: amount, contacts, date, owner.
5. **Deal row**: One grid shared by every row and header, so the cells line up; its tracks change with the layout.
6. **Amount**: Both spellings stacked in one cell: “$24k” when compact, “USD 24k” in a column. They cross-fade while the cell glides.
7. **Contacts**: Overlapping faces, only in the wide layout, blurring in row after row. A dash when a deal has none.
8. **Close date and owner**: At the right edge when compact, in columns of their own when wider. The owner is a single face.

## When to use

- A grouped list that lives in a container whose width changes: a side panel, a split view, a resizable dashboard card.
- Records with a name and three or four short fields (an amount, a date, an owner, people) that fit one line when narrow.
- Groups that read as stages or statuses, where folding one away or filtering out the finished ones helps.
- When the same list is placed in several widths of the product and should look designed in each, not squeezed.

## When not to use

- Many columns to sort, resize or compare. Use a data table with real column headers.
- Records that need editing in place. The cells here are read-only text and faces.
- Long flat lists with no groups, or hundreds of rows. Use a virtualised list; this one animates every moving cell.
- When only the window’s width matters and the list always fills it. Plain media queries are simpler.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/adaptive-list.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 { AdaptiveList } from './adaptive-list';
import { DEALS } from './deals';

export function DealsPanel() {
  return (
    // The list fills its container and adapts to its width.
    <aside style={{ width: 'min(100%, 720px)', height: 640 }}>
      <AdaptiveList title="Deals" stages={DEALS} breakpoints={[616, 717]} />
    </aside>
  );
}
```

## States

- **Compact**: Under 616 px: each deal reads as a line, the amount after the name, the date and owner on the right.
- **Columns**: From 616 px: the amount takes a column and spells its currency, the dates line up, headers show icons.
- **Wide**: From 717 px: a column of contacts joins in. Both breakpoints are props.
- **Stage folded**: A header pressed: its rows fold away under it, the count still says how many there are.
- **Open deals only**: The filter on: the won stage is gone and the total counts the open deals.
- **Deal with no contacts**: In the wide layout an empty contacts cell shows a faint dash, so the column still reads.

## How it works

- It measures itself, not the window: a ResizeObserver reads the list’s own width, so it adapts in a side panel, a split view or a dashboard card.
- Three layouts share one markup. Compact, columns and wide differ only in a data attribute on the list, which sets the grid’s tracks and where each cell sits.
- When a breakpoint is crossed it plays FLIP: it records where the moving cells are, switches the layout, measures where they landed, and animates each one back from its old place with the Web Animations API, 22 ms after the row above.
- Both spellings of an amount stay in the cell, stacked. They cross-fade, with a touch of blur, while the cell travels, so “$450” turns into “USD 450” mid-flight.
- The contacts and the columns’ icons arrive with a short blur-in, row after row. A switch that interrupts another starts from where each cell is on screen.
- The list’s and the playground’s colours are light-dark() tokens, the SVG status icons included, so both follow the page’s color-scheme, or the colorScheme prop (the list takes the playground’s); the one monochrome logo with no tile of its own is reversed at night.

## API reference

- **stages** (`Stage[]`): The groups, in order: an id, a label, a status (lead, demo, proposal or won) and their deals. A deal has a company, a 16 × 16 logo, an amount, a close date, an owner and its contacts.
- **title** (`string`, default `'Deals'`): The heading of the list.
- **breakpoints** (`readonly [number, number]`, default `[616, 717]`): The list’s widths, in pixels, at which the columns and then the contacts appear. Sizes are in em: scale the list with its font-size and scale these with it.
- **defaultOpenOnly** (`boolean`, default `false`): Opens with the filter on: only the open deals, the won stage folded away.
- **defaultCollapsed** (`readonly string[]`): The ids of the stages that open folded, their rows hidden under the header.
- **onLayoutChange** (`(layout: 'compact' | 'columns' | 'wide') => void`): Called with the new layout each time the list’s width crosses a breakpoint.
- **onFilterChange** (`(openOnly: boolean) => void`): Called when the filter is turned on or off.
- **onStageToggle** (`(id: string, open: boolean) => void`): Called when a stage’s header folds or unfolds its rows.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the list’s root.

## Accessibility

- Each stage folds from its header, a button with aria-expanded; the filter is a toggle button with aria-pressed.
- The demo’s resize handle is a separator: focus it and use the arrow keys (Shift for bigger steps), or Home and End.
- Under reduced motion the layouts switch at once, without movement, blur or stagger.

## Source

### adaptive-list/adaptive-list.tsx

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

'use client';

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { Avatar, type Person } from './avatars';
import {
  CalendarIcon,
  ContactIcon,
  DealsIcon,
  FilterIcon,
  OwnerIcon,
  StatusIcon,
  TagIcon,
  type Status,
} from './icons';
import styles from './adaptive-list.module.css';

/*
 * Adaptive list: a grouped list that lays itself out for the room it has, and animates between
 * layouts as its container resizes.
 *
 * - compact: each row reads as a line, the amount after the name ("Tideline $450"), the date
 *   and the owner on the right;
 * - columns: the amount moves into a column of its own and spells its currency ("USD 450"), the
 *   date gets a column, and each group's header shows the columns' icons;
 * - wide: a column of contacts joins them.
 *
 * The list watches its own width (ResizeObserver), so it adapts wherever it is placed: a side
 * panel, a split view, a dashboard card. When a breakpoint is crossed it animates the change
 * with FLIP: it reads where the moving cells are, switches the layout, reads where they landed,
 * and plays each cell from the old place to the new one, row after row. The two spellings of an
 * amount cross-fade while the cell travels. Under reduced motion the layout simply switches.
 *
 * Light or dark: every colour is a light-dark() token in the CSS, the status icons' included, so
 * the scheme is the page's color-scheme, or the one the colorScheme prop sets. The logos and
 * faces keep their colours.
 */

export type Deal = {
  id: string;
  company: string;
  /** A 16 × 16 mark. */
  logo: ReactNode;
  amount: number;
  /** The date the deal is expected to close, as shown ("27 Aug"). */
  close: string;
  owner: Person;
  contacts: Person[];
};

export type Stage = { id: string; label: string; status: Status; deals: Deal[] };

export type Layout = 'compact' | 'columns' | 'wide';

/** 450 → "450", 24 000 → "24k". */
function figure(amount: number) {
  return amount >= 1000 ? `${Math.round(amount / 1000)}k` : String(amount);
}

function layoutFor(width: number, [columns, wide]: readonly [number, number]): Layout {
  return width >= wide ? 'wide' : width >= columns ? 'columns' : 'compact';
}

const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
/** Each row starts this much after the one above it. */
const STAGGER = 22;

/**
 * Switches the list to `next`. With `animate`, the cells that move (marked `data-flip`) travel
 * from where they were to where the new layout puts them.
 */
function switchLayout(list: HTMLElement, next: Layout, animate: boolean) {
  const movers = animate ? Array.from(list.querySelectorAll<HTMLElement>('[data-flip]')) : [];
  // Where each cell is on screen now, mid-flight included, so a reversal starts from there.
  const before = movers.map((cell) => cell.getBoundingClientRect());
  for (const cell of movers) for (const running of cell.getAnimations()) running.cancel();
  list.dataset.layout = next;
  // Screen pixels to the list's own, should the list sit in a scaled container.
  const scale = list.getBoundingClientRect().width / list.offsetWidth || 1;
  movers.forEach((cell, i) => {
    const from = before[i];
    const to = cell.getBoundingClientRect();
    // Hidden before or after (a header icon that comes or goes): its own fade handles it.
    if (!from.width || !to.width) return;
    const dx = (from.left - to.left) / scale;
    const dy = (from.top - to.top) / scale;
    if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
    cell.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
      duration: 560,
      easing: EASE,
      delay: Number(cell.dataset.row) * STAGGER,
      fill: 'backwards',
    });
  });
}

export function AdaptiveList({
  title = 'Deals',
  stages,
  breakpoints = [616, 717],
  colorScheme,
  defaultOpenOnly = false,
  defaultCollapsed,
  onLayoutChange,
  onFilterChange,
  onStageToggle,
  className = '',
}: {
  title?: string;
  stages: Stage[];
  /** The widths, in pixels, at which the columns and then the contacts appear. */
  breakpoints?: readonly [number, number];
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens with the filter on: only the open deals, the won stage folded away. */
  defaultOpenOnly?: boolean;
  /** The ids of the stages that open folded, their rows hidden under the header. */
  defaultCollapsed?: readonly string[];
  /** Called with the new layout each time the list's width crosses a breakpoint. */
  onLayoutChange?: (layout: Layout) => void;
  /** Called when the filter is turned on or off. */
  onFilterChange?: (openOnly: boolean) => void;
  /** Called when a stage's header folds or unfolds its rows. */
  onStageToggle?: (id: string, open: boolean) => void;
  className?: string;
}) {
  const list = useRef<HTMLElement>(null);
  const [openOnly, setOpenOnly] = useState(defaultOpenOnly);
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
    Object.fromEntries((defaultCollapsed ?? []).map((id) => [id, true])),
  );
  const [columns, wide] = breakpoints;
  const layoutChanged = useRef(onLayoutChange);
  useEffect(() => {
    layoutChanged.current = onLayoutChange;
  });

  useEffect(() => {
    const element = list.current;
    if (!element) return;
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
    let layout = element.dataset.layout as Layout;
    let settled = false;
    const observer = new ResizeObserver(([entry]) => {
      const next = layoutFor(entry.contentRect.width, [columns, wide]);
      if (next !== layout) {
        // The first reading only sets the layout: nothing has moved yet.
        switchLayout(element, next, settled && !reduce.matches);
        layout = next;
        layoutChanged.current?.(next);
      }
      settled = true;
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [columns, wide]);

  const shown = stages.filter((stage) => !(openOnly && stage.status === 'won'));
  const total = shown.reduce((sum, stage) => sum + stage.deals.length, 0);

  // One running index over headers and rows, for the stagger.
  let row = 0;

  return (
    <section
      ref={list}
      className={`${styles.list} ${className}`}
      data-color-scheme={colorScheme}
      data-layout="compact"
      data-part="list"
      aria-label={title}
    >
      <header className={styles.bar} data-part="bar">
        <DealsIcon className={styles.barIcon} />
        <h3 className={styles.title}>{title}</h3>
        <span className={styles.total} data-part="total" aria-label={`${total} deals`}>
          {total}
        </span>
        <button
          type="button"
          className={styles.filter}
          data-part="filter"
          aria-pressed={openOnly}
          aria-label="Show open deals only"
          title="Open deals only"
          onClick={() => {
            setOpenOnly(!openOnly);
            onFilterChange?.(!openOnly);
          }}
        >
          <FilterIcon />
        </button>
      </header>

      <div className={styles.body}>
        {stages.map((stage) => {
          const hidden = openOnly && stage.status === 'won';
          const open = !collapsed[stage.id] && !hidden;
          const headerRow = row++;
          const firstRow = row;
          row += stage.deals.length;
          return (
            <section
              key={stage.id}
              className={styles.stage}
              data-hidden={hidden || undefined}
              aria-hidden={hidden || undefined}
              inert={hidden || undefined}
            >
              <div className={styles.stageInner}>
                <button
                  type="button"
                  className={`${styles.grid} ${styles.stageHeader}`}
                  data-part="stage-header"
                  style={{ '--row': headerRow } as CSSProperties}
                  aria-expanded={open}
                  onClick={() => {
                    setCollapsed((now) => ({ ...now, [stage.id]: !now[stage.id] }));
                    onStageToggle?.(stage.id, !open);
                  }}
                >
                  <span className={styles.stageLabel}>
                    <StatusIcon
                      status={stage.status}
                      className={styles.status}
                      data-part="status"
                    />
                    <span className={styles.stageName}>{stage.label}</span>
                    <span className={styles.count} data-part="count">
                      {stage.deals.length}
                    </span>
                  </span>
                  <span
                    className={`${styles.colIcon} ${styles.amountCell}`}
                    data-part="column-icon"
                    data-flip
                    data-row={headerRow}
                  >
                    <TagIcon />
                  </span>
                  <span className={`${styles.colIcon} ${styles.contactsCell}`} data-row={headerRow}>
                    <ContactIcon />
                  </span>
                  <span
                    className={`${styles.colIcon} ${styles.closeCell}`}
                    data-flip
                    data-row={headerRow}
                  >
                    <CalendarIcon />
                  </span>
                  <span className={`${styles.colIcon} ${styles.ownerCell}`} data-row={headerRow}>
                    <OwnerIcon />
                  </span>
                </button>

                <div className={styles.fold} data-open={open || undefined}>
                  <ul className={styles.rows}>
                    {stage.deals.map((deal, i) => {
                      const index = firstRow + i;
                      return (
                        <li
                          key={deal.id}
                          className={`${styles.grid} ${styles.row}`}
                          data-part="row"
                          style={{ '--row': index } as CSSProperties}
                        >
                          <span className={styles.name} data-part="name">
                            <span className={styles.logo} data-part="logo">
                              {deal.logo}
                            </span>
                            <span className={styles.company}>{deal.company}</span>
                          </span>
                          <span
                            className={styles.amountCell}
                            data-part="amount"
                            data-flip
                            data-row={index}
                          >
                            <span className={styles.short}>${figure(deal.amount)}</span>
                            <span className={styles.long}>USD {figure(deal.amount)}</span>
                          </span>
                          <span className={styles.contactsCell} data-part="contacts">
                            {deal.contacts.length ? (
                              deal.contacts.map((person) => (
                                <span
                                  key={person.name}
                                  className={styles.contact}
                                  data-part="contact"
                                >
                                  <Avatar person={person} />
                                </span>
                              ))
                            ) : (
                              <span className={styles.none} aria-label="No contacts">
                                –
                              </span>
                            )}
                          </span>
                          <span
                            className={styles.closeCell}
                            data-part="close"
                            data-flip
                            data-row={index}
                          >
                            {deal.close}
                          </span>
                          <span className={styles.ownerCell} data-part="owner">
                            <Avatar person={deal.owner} className={styles.owner} />
                          </span>
                        </li>
                      );
                    })}
                  </ul>
                </div>
              </div>
            </section>
          );
        })}
      </div>
    </section>
  );
}
```

### adaptive-list/adaptive-list.module.css

```css
/* Adaptive list (adaptive-list.tsx). Sizes in em: set a font-size on the list to scale it. The
   layout is a data attribute the component sets from its width: compact, columns or wide. It has
   no ground of its own: it sits on a light panel, or at night on a dark grey one. */

.list {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the list
     inherits from the page, or the one the colorScheme prop sets (below). */
  /* The ground it sits on (the contacts' faces are cut out of it), then the stage headers a step
     up, and the counts on them. */
  --surface: #fff;
  --surface-2: #f4f4f5;
  --chip: #e8e8ea;
  /* Names and titles; amounts, dates, icons and counts; the quietest marks. */
  --ink: #1d1d1f;
  --ink-muted: #86868b;
  --ink-faint: #aeaeb2;
  --line: #e8e8ea;
  /* A row under the pointer. */
  --hover: rgb(0 0 0 / 0.028);
  /* A logo drawn in one dark colour, with no tile of its own, reversed at night (logos.tsx). The
     other logos and the faces keep their colours. */
  --logo-ink: #111;
  /* The status icons' green, the check on it once a deal is won, and a lead's paler ring. */
  --accent: #1b8a3a;
  --accent-ink: #fff;
  --accent-soft: #7cc58e;
  --focus: #0a84ff;
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  display: flex;
  flex-direction: column;
  min-width: 0;
  height: 100%;
  font:
    400 14px/1.3 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
@supports (color: light-dark(#fff, #000)) {
  .list {
    --surface: light-dark(#fff, #1c1c1e);
    --surface-2: light-dark(#f4f4f5, #2a2a2d);
    --chip: light-dark(#e8e8ea, #3a3a3d);
    --ink: light-dark(#1d1d1f, #f5f5f7);
    --ink-muted: light-dark(#86868b, #98989d);
    --ink-faint: light-dark(#aeaeb2, #6e6e73);
    --line: light-dark(#e8e8ea, #2e2e31);
    --hover: light-dark(rgb(0 0 0 / 0.028), rgb(255 255 255 / 0.04));
    --logo-ink: light-dark(#111, #f5f5f7);
    --accent: light-dark(#1b8a3a, #30d158);
    --accent-ink: light-dark(#fff, #1c1c1e);
    --accent-soft: light-dark(#7cc58e, #358f52);
    --focus: light-dark(#0a84ff, #3594ff);
  }
}
.list button {
  font: inherit;
  color: inherit;
}

/* The colorScheme prop. Without it the list sets nothing and inherits the page's color-scheme. */
.list[data-color-scheme='light'] {
  color-scheme: light;
}
.list[data-color-scheme='dark'] {
  color-scheme: dark;
}
.list[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The title bar. */
.bar {
  display: flex;
  align-items: center;
  gap: 0.55em;
  padding: 1.1em 1.25em 0.75em 1.35em;
}
.barIcon {
  width: 1.2em;
  height: 1.2em;
  color: var(--ink-muted);
}
.title {
  margin: 0;
  font-size: 1.07em;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.total {
  color: var(--ink-faint);
  font-weight: 500;
}
.filter {
  display: grid;
  place-items: center;
  width: 2em;
  height: 2em;
  margin-left: auto;
  padding: 0;
  border: 0;
  border-radius: 0.5em;
  background: transparent;
  color: var(--ink-muted);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.filter svg {
  width: 1.15em;
  height: 1.15em;
}
.filter:hover,
.filter[aria-pressed='true'] {
  background: var(--surface-2);
  color: var(--ink);
}

.body {
  flex: 1;
  min-height: 0;
  overflow: auto;
  padding: 0 0.75em 0.75em;
  scrollbar-width: thin;
}

/* Every header and row shares one grid, whose tracks change with the layout. */
.grid {
  display: grid;
  align-items: center;
  column-gap: 0.6em;
  grid-template-columns: auto auto minmax(0, 1fr) auto auto;
  min-height: 2.85em;
  padding: 0 0.85em;
}
.list[data-layout='columns'] .grid {
  column-gap: 1em;
  grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(0, 0.85fr) auto;
}
.list[data-layout='wide'] .grid {
  column-gap: 1em;
  grid-template-columns:
    minmax(0, 1.3fr) minmax(0, 0.62fr) minmax(0, 0.48fr) minmax(0, 0.8fr)
    auto;
}

/* Where each cell sits, per layout. */
.name,
.stageLabel {
  grid-column: 1;
}
.amountCell {
  grid-column: 2;
}
.contactsCell {
  display: none;
}
.closeCell {
  grid-column: 4;
  justify-self: end;
}
.ownerCell {
  grid-column: 5;
  margin-left: 0.55em;
}
.list[data-layout='columns'] .closeCell {
  grid-column: 3;
  justify-self: start;
}
.list[data-layout='columns'] .ownerCell {
  grid-column: 4;
  margin-left: 0;
}
.list[data-layout='wide'] .contactsCell {
  grid-column: 3;
}
.list[data-layout='wide'] .closeCell {
  grid-column: 4;
  justify-self: start;
}
.list[data-layout='wide'] .ownerCell {
  grid-column: 5;
  margin-left: 0;
}

/* A stage: its header, then its rows, which fold away. */
.stage {
  display: grid;
  grid-template-rows: 1fr;
  transition:
    grid-template-rows 0.34s var(--ease),
    opacity 0.24s ease;
}
.stage[data-hidden] {
  grid-template-rows: 0fr;
  opacity: 0;
}
.stageInner {
  min-height: 0;
  overflow: hidden;
}
.stageHeader {
  width: 100%;
  min-height: 2.7em;
  margin: 0.2em 0;
  border: 0;
  border-radius: 0.75em;
  background: var(--surface-2);
  text-align: left;
  cursor: pointer;
}
.stageHeader:focus-visible,
.filter:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.stageLabel {
  display: flex;
  align-items: center;
  gap: 0.55em;
  min-width: 0;
}
.status {
  flex: none;
  width: 1.15em;
  height: 1.15em;
}
.stageName {
  font-weight: 500;
  white-space: nowrap;
}
.count {
  display: grid;
  place-items: center;
  min-width: 1.6em;
  height: 1.6em;
  padding: 0 0.4em;
  border-radius: 999px;
  background: var(--chip);
  font-size: 0.8em;
  font-weight: 500;
  color: var(--ink-muted);
}

.fold {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.34s var(--ease);
}
.fold[data-open] {
  grid-template-rows: 1fr;
}
.rows {
  min-height: 0;
  margin: 0;
  padding: 0;
  overflow: hidden;
  list-style: none;
}

/* A deal. */
.row {
  min-height: 2.85em;
  border-radius: 0.65em;
  transition: background-color 0.15s ease;
}
.row:hover {
  background: var(--hover);
}
.name {
  display: flex;
  align-items: center;
  gap: 0.6em;
  min-width: 0;
}
.logo {
  display: grid;
  flex: none;
}
.logo svg,
.logo img {
  width: 1.15em;
  height: 1.15em;
  border-radius: 0.28em;
}
.company {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The amount, in both spellings, stacked: the layout shows one and fades the other. */
.amountCell {
  display: grid;
  justify-items: start;
  white-space: nowrap;
  color: var(--ink-muted);
}
.short,
.long {
  grid-area: 1 / 1;
  transition:
    opacity 0.32s ease,
    filter 0.32s ease;
  transition-delay: calc(var(--row) * 22ms + 40ms);
}
.list[data-layout='compact'] .long,
.list:not([data-layout='compact']) .short {
  opacity: 0;
  filter: blur(3px);
}

.closeCell {
  white-space: nowrap;
  color: var(--ink-muted);
}

.ownerCell {
  display: grid;
}
.owner {
  width: 1.2em;
  height: 1.2em;
  border-radius: 50%;
}

/* Contacts, in the wide layout: small faces that overlap, blurring in row after row. */
.list[data-layout='wide'] .contactsCell {
  display: flex;
  align-items: center;
  animation: arrive 0.48s var(--ease) both;
  animation-delay: calc(var(--row) * 26ms + 90ms);
}
.contact {
  display: grid;
  width: 1.2em;
  height: 1.2em;
  border-radius: 50%;
  box-shadow: 0 0 0 1.5px var(--surface);
}
.contact + .contact {
  margin-left: -0.4em;
}
.contact svg {
  width: 100%;
  height: 100%;
  border-radius: 50%;
}
.none {
  color: var(--ink-faint);
}

/* The columns' icons, in each stage's header. */
.colIcon {
  display: none;
  color: var(--ink-faint);
}
.colIcon svg {
  width: 1.15em;
  height: 1.15em;
}
.list:not([data-layout='compact']) .colIcon:not(.contactsCell) {
  display: grid;
  animation: arrive 0.4s var(--ease) both;
  animation-delay: calc(var(--row) * 22ms + 60ms);
}
.list[data-layout='wide'] .colIcon.contactsCell {
  display: grid;
}
.ownerCell.colIcon {
  justify-self: center;
}

@keyframes arrive {
  from {
    opacity: 0;
    filter: blur(4px);
    transform: scale(0.86);
  }
}

@media (prefers-reduced-motion: reduce) {
  .short,
  .long,
  .stage,
  .fold {
    transition: none;
  }
  .list[data-layout='wide'] .contactsCell,
  .list:not([data-layout='compact']) .colIcon:not(.contactsCell) {
    animation: none;
  }
}
```

### adaptive-list/icons.tsx

```tsx
import type { SVGProps } from 'react';

/*
 * The adaptive list's icons, drawn on a 16 × 16 grid with 1.25px strokes in `currentColor`, so they
 * take the colour of the text around them.
 */

type IconProps = SVGProps<SVGSVGElement>;

function Icon({ children, ...props }: IconProps) {
  return (
    <svg
      viewBox="0 0 16 16"
      width="16"
      height="16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.25"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      {children}
    </svg>
  );
}

/** Three overlapping rings: the list's own icon. */
export function DealsIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="8" cy="5.9" r="3.4" />
      <circle cx="5.6" cy="10" r="3.4" />
      <circle cx="10.4" cy="10" r="3.4" />
    </Icon>
  );
}

export function FilterIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M2.75 4.75h10.5M4.75 8h6.5M6.75 11.25h2.5" />
    </Icon>
  );
}

/** The amount column. */
export function TagIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M8.6 2.5H13a.5.5 0 0 1 .5.5v4.4a1 1 0 0 1-.3.7l-5.6 5.6a1 1 0 0 1-1.4 0L2.3 9.8a1 1 0 0 1 0-1.4l5.6-5.6a1 1 0 0 1 .7-.3Z" />
      <circle cx="10.6" cy="5.4" r=".8" fill="currentColor" stroke="none" />
    </Icon>
  );
}

/** The contacts column. */
export function ContactIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="8" cy="8" r="5.75" />
      <circle cx="8" cy="6.8" r="2" />
      <path d="M4.4 12.4c.8-1.4 2.1-2.1 3.6-2.1s2.8.7 3.6 2.1" />
    </Icon>
  );
}

/** The close date column. */
export function CalendarIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <rect x="2.5" y="3.25" width="11" height="10.25" rx="1.5" />
      <path d="M2.5 6.25h11M5.5 2v2.5M10.5 2v2.5M6 9.6l1.4 1.4 2.6-2.6" />
    </Icon>
  );
}

/** The owner column. */
export function OwnerIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="8" cy="8" r="5.75" />
      <path d="M5.7 9.6c.5.8 1.3 1.2 2.3 1.2s1.8-.4 2.3-1.2" />
      <circle cx="6.1" cy="6.6" r=".7" fill="currentColor" stroke="none" />
      <circle cx="9.9" cy="6.6" r=".7" fill="currentColor" stroke="none" />
    </Icon>
  );
}

export type Status = 'lead' | 'demo' | 'proposal' | 'won';

/**
 * A stage's progress, in the green of a pipeline: an open ring for leads, a ring that fills (a solid
 * arc, then dots for what is left) for demos and proposals, and a check once the deal is won. Its
 * colours are the list's (--accent, --accent-ink, --accent-soft), light or dark.
 */
export function StatusIcon({ status, ...props }: IconProps & { status: Status }) {
  const r = 5.25;
  const length = 2 * Math.PI * r;
  // The share of the ring drawn solid, from twelve o'clock; ten dots would close the ring.
  const done = status === 'proposal' ? 0.75 : 0.25;
  const gap = length / 10;
  return (
    <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden="true" {...props}>
      {status === 'won' ? (
        <>
          <circle cx="8" cy="8" r="6" fill="var(--accent)" />
          <path
            d="m5.4 8.2 1.8 1.8 3.4-3.6"
            stroke="var(--accent-ink)"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </>
      ) : status === 'lead' ? (
        <circle cx="8" cy="8" r={r} stroke="var(--accent-soft)" strokeWidth="1.7" />
      ) : (
        <g
          transform="rotate(-90 8 8)"
          stroke="var(--accent)"
          strokeWidth="1.7"
          strokeLinecap="round"
        >
          <circle cx="8" cy="8" r={r} strokeDasharray={`${length * done} ${length}`} />
          {/* Dots all round the ring: under the solid arc they disappear into it. */}
          <circle
            cx="8"
            cy="8"
            r={r}
            strokeDasharray={`0 ${gap}`}
            strokeDashoffset={-(length * done + gap * 0.75)}
          />
        </g>
      )}
    </svg>
  );
}
```

### adaptive-list/avatars.tsx

```tsx
/*
 * Small illustrated people for the sample deals' owners and contacts: no photos, so the demo needs
 * no image files and shows no real faces. Use an <img> in their place for real people.
 */

export type Face = {
  /** The backdrop, behind the head. */
  bg: string;
  skin: string;
  hair: string;
  shirt: string;
  style: 'short' | 'long' | 'bun' | 'curly' | 'bald';
  beard?: boolean;
  glasses?: boolean;
};

export type Person = { name: string; face: Face };

export function Avatar({ person, className }: { person: Person; className?: string }) {
  const { bg, skin, hair, shirt, style, beard, glasses } = person.face;
  return (
    <svg className={className} viewBox="0 0 24 24" role="img" aria-label={person.name}>
      <circle cx="12" cy="12" r="12" fill={bg} />
      {style === 'long' && (
        <path
          d="M6.6 11.8c-.6-4.7 1.9-7.6 5.4-7.6s6 2.9 5.4 7.6c.3 2.5.8 4.2 1.7 5.5-2.7.8-5 .4-7.1-.6-2.1 1-4.4 1.4-7.1.6.9-1.3 1.4-3 1.7-5.5Z"
          fill={hair}
        />
      )}
      <path d="M3.6 24c.8-4.6 4.1-7.1 8.4-7.1s7.6 2.5 8.4 7.1Z" fill={shirt} />
      <rect x="10.2" y="13.6" width="3.6" height="3.8" rx="1.6" fill={skin} />
      <circle cx="12" cy="10.6" r="4.6" fill={skin} />
      {beard && (
        <path
          d="M7.6 11.2c.4 3.2 2.2 4.8 4.4 4.8s4-1.6 4.4-4.8c-1 .8-2.4 1.2-4.4 1.2s-3.4-.4-4.4-1.2Z"
          fill={hair}
        />
      )}
      {(style === 'short' || style === 'bun' || style === 'long') && (
        <path
          d="M7.2 10.4c-.3-3.8 2-5.9 4.9-5.9 3 0 5.1 2.1 4.8 5.9-.6-1.7-1.9-2.8-3.7-3-1.5.9-3.7 1.5-6 3Z"
          fill={hair}
        />
      )}
      {style === 'bun' && <circle cx="12" cy="4.3" r="2.1" fill={hair} />}
      {style === 'curly' && (
        <g fill={hair}>
          <circle cx="8.2" cy="8.4" r="2.2" />
          <circle cx="10.4" cy="6.4" r="2.3" />
          <circle cx="13.4" cy="6.3" r="2.3" />
          <circle cx="15.8" cy="8.3" r="2.2" />
          <circle cx="7.4" cy="10.8" r="1.6" />
          <circle cx="16.6" cy="10.8" r="1.6" />
        </g>
      )}
      {glasses && (
        <g fill="none" stroke="#1d1d1f" strokeWidth=".75">
          <circle cx="10.1" cy="11" r="1.3" />
          <circle cx="13.9" cy="11" r="1.3" />
          <path d="M11.4 11h1.2" />
        </g>
      )}
    </svg>
  );
}
```

### adaptive-list/logos.tsx

```tsx
/*
 * Logos for the sample deals: made-up companies, each a 16 × 16 mark. Swap them for your own
 * customers' logos (an <img> works as well as an <svg>).
 */

type LogoProps = { className?: string };

function Mark({
  className,
  background,
  children,
}: LogoProps & { background?: string; children: React.ReactNode }) {
  return (
    <svg className={className} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
      {background && <rect width="16" height="16" rx="4" fill={background} />}
      {children}
    </svg>
  );
}

export function TidelineLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#1d6ff2">
      <path d="M8 3.4c1.7 2 2.7 3.6 2.7 5a2.7 2.7 0 0 1-5.4 0c0-1.4 1-3 2.7-5Z" fill="#fff" />
      <path d="M8 8.2c.6.7.9 1.2.9 1.6a.9.9 0 0 1-1.8 0c0-.4.3-.9.9-1.6Z" fill="#8fbcff" />
    </Mark>
  );
}

export function BrambleLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#fbd8ca">
      <path d="M8.3 4a3.7 3.7 0 0 1 0 7.4H4.6V7.7A3.7 3.7 0 0 1 8.3 4Z" fill="#c2410c" />
    </Mark>
  );
}

export function AshfordLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#b4461f">
      <path
        d="M3.4 11.6 6 7.3l1.6 2.3 1.5-1.8 3.5 3.8Z"
        fill="#f0b0d0"
        stroke="#f0b0d0"
        strokeWidth=".6"
        strokeLinejoin="round"
      />
    </Mark>
  );
}

export function FieldedLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#0f2b16">
      <rect
        x="3.9"
        y="3.9"
        width="8.2"
        height="8.2"
        rx="1.6"
        fill="none"
        stroke="#fff"
        strokeWidth="1.3"
      />
      <path d="m5.2 11.2 2.8-3 2.8 3Z" fill="#fff" />
    </Mark>
  );
}

export function AlviLogo({ className }: LogoProps) {
  return (
    <Mark className={className}>
      <path
        d="M2.6 12.2 7.9 2.6c.3-.5.9-.5 1.2 0l2 3.6-5.5 7.4c-.3.4-.7.5-1.1.3l-1.6-.9c-.4-.2-.5-.5-.3-.8Z"
        fill="#5a2df4"
      />
      <path d="m11.1 6.2 2.3 4.2c.3.5 0 1-.5 1.1l-7.3 2.1Z" fill="#8f6dff" />
    </Mark>
  );
}

export function VesperLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#36267f">
      <path d="M9.4 4.3a3.9 3.9 0 1 0 2.3 6.7A3.5 3.5 0 0 1 9.4 4.3Z" fill="#cfc2ff" />
      <circle cx="11" cy="5.2" r=".7" fill="#cfc2ff" />
    </Mark>
  );
}

export function HollowayLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#f5f50c">
      <path
        d="M8 3.6v8.8M4.2 5.8l7.6 4.4M4.2 10.2l7.6-4.4"
        stroke="#111"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </Mark>
  );
}

export function NorthlaneLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#1f9b2b">
      <path
        d="M4.2 11.4 6.8 4.6M7 11.4l2.6-6.8M9.8 11.4l2.6-6.8"
        stroke="#d6f24c"
        strokeWidth="1.3"
        strokeLinecap="round"
      />
    </Mark>
  );
}

/** One dark colour and no tile: the list reverses it at night (--logo-ink). */
export function QuietworkLogo({ className }: LogoProps) {
  return (
    <Mark className={className}>
      <rect x="3.6" y="4.2" width="3.4" height="7.6" rx="1.7" fill="var(--logo-ink, #111)" />
      <rect x="8.4" y="5.6" width="1.4" height="4.8" rx=".7" fill="var(--logo-ink, #111)" />
      <rect x="10.9" y="5.6" width="1.4" height="4.8" rx=".7" fill="var(--logo-ink, #111)" />
    </Mark>
  );
}

export function WovenpathLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#141a2b">
      <path
        d="M3.8 5.6c2.6 0 5.8 4.8 8.4 4.8M3.8 10.4c2.6 0 5.8-4.8 8.4-4.8M3.8 4.8v1.6M3.8 9.6v1.6M12.2 4.8v1.6M12.2 9.6v1.6"
        stroke="#fff"
        strokeWidth="1.3"
        strokeLinecap="round"
        fill="none"
      />
    </Mark>
  );
}

export function HaldenLogo({ className }: LogoProps) {
  return (
    <Mark className={className} background="#dedede">
      <path d="M3.6 5.3h2.5L8 6.9l1.9-1.6h2.5v5.4H9.9L8 9.1l-1.9 1.6H3.6Z" fill="#f2611d" />
    </Mark>
  );
}
```

### adaptive-list/deals.tsx

```tsx
import type { Person } from './avatars';
import type { Stage } from './adaptive-list';
import {
  AlviLogo,
  AshfordLogo,
  BrambleLogo,
  FieldedLogo,
  HaldenLogo,
  HollowayLogo,
  NorthlaneLogo,
  QuietworkLogo,
  TidelineLogo,
  VesperLogo,
  WovenpathLogo,
} from './logos';

/* Sample pipeline for the demo: made-up companies, people and amounts. */

const people = {
  maya: {
    name: 'Maya',
    face: { bg: '#3a2a1f', skin: '#c58c63', hair: '#2b1a12', shirt: '#7a4a2a', style: 'curly' },
  },
  noah: {
    name: 'Noah',
    face: {
      bg: '#d9d4cf',
      skin: '#e2b49a',
      hair: '#8a6a55',
      shirt: '#4b5563',
      style: 'bald',
      beard: true,
    },
  },
  theo: {
    name: 'Theo',
    face: {
      bg: '#eceae7',
      skin: '#e8bfa0',
      hair: '#2a2a2a',
      shirt: '#1f2937',
      style: 'short',
      beard: true,
    },
  },
  ines: {
    name: 'Inês',
    face: { bg: '#7b1e3a', skin: '#b9785a', hair: '#1c1010', shirt: '#4a1424', style: 'long' },
  },
  sam: {
    name: 'Sam',
    face: {
      bg: '#cfc8c0',
      skin: '#d9a684',
      hair: '#5a3b28',
      shirt: '#9ca3af',
      style: 'short',
      glasses: true,
    },
  },
  lena: {
    name: 'Lena',
    face: { bg: '#2b2420', skin: '#e0b090', hair: '#3b2516', shirt: '#57534e', style: 'long' },
  },
  ravi: {
    name: 'Ravi',
    face: { bg: '#2f4858', skin: '#9c6b4c', hair: '#151515', shirt: '#334155', style: 'short' },
  },
  ada: {
    name: 'Ada',
    face: { bg: '#e9dfd6', skin: '#f1c8a8', hair: '#a8693c', shirt: '#b45309', style: 'bun' },
  },
  joao: {
    name: 'João',
    face: { bg: '#6b8fd6', skin: '#c99272', hair: '#3a2416', shirt: '#1e3a8a', style: 'curly' },
  },
  kai: {
    name: 'Kai',
    face: { bg: '#bfc7c2', skin: '#e3b796', hair: '#d8c08a', shirt: '#475569', style: 'short' },
  },
  zoe: {
    name: 'Zoe',
    face: {
      bg: '#d6cdc6',
      skin: '#8a5a3c',
      hair: '#1a1210',
      shirt: '#6b7280',
      style: 'bun',
      glasses: true,
    },
  },
} satisfies Record<string, Person>;

export const DEALS: Stage[] = [
  {
    id: 'proposal',
    label: 'Proposal',
    status: 'proposal',
    deals: [
      {
        id: 'tideline',
        company: 'Tideline',
        logo: <TidelineLogo />,
        amount: 450,
        close: '27 Aug',
        owner: people.maya,
        contacts: [people.kai, people.sam],
      },
      {
        id: 'bramble',
        company: 'Bramble',
        logo: <BrambleLogo />,
        amount: 24_000,
        close: '15 Oct',
        owner: people.noah,
        contacts: [people.zoe],
      },
      {
        id: 'ashford',
        company: 'Ashford',
        logo: <AshfordLogo />,
        amount: 52_000,
        close: '29 Oct',
        owner: people.theo,
        contacts: [people.ada],
      },
    ],
  },
  {
    id: 'demo',
    label: 'Demo',
    status: 'demo',
    deals: [
      {
        id: 'fielded',
        company: 'Fielded',
        logo: <FieldedLogo />,
        amount: 320,
        close: '12 Sept',
        owner: people.ines,
        contacts: [people.lena, people.ravi],
      },
      {
        id: 'alvi',
        company: 'alvi.sh',
        logo: <AlviLogo />,
        amount: 10_000,
        close: '3 Sept',
        owner: people.noah,
        contacts: [people.joao],
      },
      {
        id: 'vesper',
        company: 'Vesper',
        logo: <VesperLogo />,
        amount: 18_000,
        close: '22 Oct',
        owner: people.lena,
        contacts: [people.kai],
      },
    ],
  },
  {
    id: 'leads',
    label: 'Leads',
    status: 'lead',
    deals: [
      {
        id: 'holloway',
        company: 'Holloway',
        logo: <HollowayLogo />,
        amount: 180,
        close: '27 Sept',
        owner: people.sam,
        contacts: [people.ada, people.ravi, people.zoe],
      },
      {
        id: 'northlane',
        company: 'Northlane',
        logo: <NorthlaneLogo />,
        amount: 36_000,
        close: '5 Nov',
        owner: people.ines,
        contacts: [people.theo],
      },
      {
        id: 'quietwork',
        company: 'Quietwork',
        logo: <QuietworkLogo />,
        amount: 12_000,
        close: '12 Nov',
        owner: people.ada,
        contacts: [],
      },
    ],
  },
  {
    id: 'won',
    label: 'Closed Won',
    status: 'won',
    deals: [
      {
        id: 'wovenpath',
        company: 'Wovenpath',
        logo: <WovenpathLogo />,
        amount: 125,
        close: '10 Aug',
        owner: people.ravi,
        contacts: [people.maya],
      },
      {
        id: 'halden',
        company: 'Halden',
        logo: <HaldenLogo />,
        amount: 28_000,
        close: '21 Sept',
        owner: people.joao,
        contacts: [people.lena],
      },
    ],
  },
];
```

### adaptive-list/playground.tsx

```tsx
'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AdaptiveList, type Layout } from './adaptive-list';
import { DEALS } from './deals';
import { useAutoplay } from './use-autoplay';
import styles from './playground.module.css';

/*
 * The adaptive list's playground: the list in a side panel docked to the right of a stage, with a
 * handle on the panel's edge to resize it (drag, or arrow keys), and three widths to jump to. While
 * on screen and untouched, the panel opens and closes on its own. Its colours, and the list's,
 * follow the page's color-scheme, or the one the colorScheme prop sets.
 */

/** The list's breakpoints, shared with the read-out. */
const BREAKPOINTS = [616, 717] as const;
/** The panel's border, between its width and the list's. */
const BORDER = 2;
const MIN = 420;
const MAX = 900;
/** The stage's margin round the panel. */
const INSET = 12;

const PRESETS: { layout: Layout; label: string; width: number }[] = [
  { layout: 'compact', label: 'Compact', width: 480 },
  { layout: 'columns', label: 'Columns', width: 660 },
  { layout: 'wide', label: 'Wide', width: 820 },
];

function layoutFor(width: number): Layout {
  const inner = width - BORDER;
  return inner >= BREAKPOINTS[1] ? 'wide' : inner >= BREAKPOINTS[0] ? 'columns' : 'compact';
}

const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

export function AdaptiveListPlayground({
  className = '',
  autoplay = true,
  colorScheme,
}: {
  className?: string;
  /** Play the demonstration while visible and untouched; false leaves it all to the reader. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
}) {
  const { ref, playing: unattended, take } = useAutoplay<HTMLDivElement>(6000);
  // The demonstration plays only when allowed, on screen and untouched.
  const playing = autoplay && unattended;
  const stage = useRef<HTMLDivElement>(null);
  const panel = useRef<HTMLDivElement>(null);
  const width = useRef(PRESETS[0].width);
  const bounds = useRef({ min: MIN, max: MAX });
  const frame = useRef(0);
  const [shown, setShown] = useState({ width: PRESETS[0].width, layout: 'compact' as Layout });
  const [room, setRoom] = useState(MAX);

  /** Sets the panel's width (clamped to the stage), without a React render per frame. */
  const apply = useCallback((next: number) => {
    const { min, max } = bounds.current;
    const value = Math.round(Math.min(max, Math.max(min, next)));
    width.current = value;
    panel.current?.style.setProperty('--panel', `${value}px`);
    setShown((now) => (now.width === value ? now : { width: value, layout: layoutFor(value) }));
  }, []);

  /** Eases the panel to `to` over `ms`; resolves when it gets there (or is interrupted). */
  const glide = useCallback(
    (to: number, ms: number) =>
      new Promise<void>((resolve) => {
        cancelAnimationFrame(frame.current);
        const from = width.current;
        const start = performance.now();
        const step = (now: number) => {
          const t = Math.min(1, Math.max(0, (now - start) / ms));
          apply(from + (to - from) * easeInOut(t));
          if (t < 1) frame.current = requestAnimationFrame(step);
          else resolve();
        };
        frame.current = requestAnimationFrame(step);
      }),
    [apply],
  );

  // The stage's room: the panel never outgrows it.
  useEffect(() => {
    const element = stage.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) => {
      const available = Math.floor(entry.contentRect.width - INSET * 2);
      bounds.current = { min: Math.min(MIN, available), max: Math.min(MAX, available) };
      setRoom(available);
      apply(width.current);
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [apply]);

  // The demo: open to wide, settle on columns, close to compact; again while untouched.
  useEffect(() => {
    if (!playing) return;
    let cancelled = false;
    const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
    (async () => {
      while (!cancelled) {
        await wait(1100);
        if (cancelled) break;
        await glide(820, 1500);
        await wait(1500);
        if (cancelled) break;
        await glide(660, 900);
        await wait(1500);
        if (cancelled) break;
        await glide(480, 1300);
        await wait(1400);
      }
    })();
    return () => {
      cancelled = true;
      cancelAnimationFrame(frame.current);
    };
  }, [playing, glide]);

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

  const drag = useRef<{ x: number; width: number; scale: number } | null>(null);
  // The list's props never change: the same element lets React skip it while the panel moves.
  const list = useMemo(() => <AdaptiveList stages={DEALS} breakpoints={BREAKPOINTS} />, []);

  return (
    <div ref={ref} className={`${styles.root} ${className}`} data-color-scheme={colorScheme}>
      <div ref={stage} className={styles.stage}>
        <div
          ref={panel}
          className={styles.panel}
          style={{ '--panel': `${PRESETS[0].width}px` } as React.CSSProperties}
        >
          <div
            className={styles.handle}
            role="separator"
            aria-orientation="vertical"
            aria-label="Resize the panel"
            aria-valuemin={bounds.current.min}
            aria-valuemax={bounds.current.max}
            aria-valuenow={shown.width}
            aria-valuetext={`${shown.width} pixels, ${shown.layout} layout`}
            tabIndex={0}
            onPointerDown={(event) => {
              take();
              cancelAnimationFrame(frame.current);
              event.currentTarget.setPointerCapture(event.pointerId);
              // Screen pixels to the stage's own, should the playground sit in a scaled container.
              const box = stage.current;
              const scale = box ? box.getBoundingClientRect().width / box.offsetWidth || 1 : 1;
              drag.current = { x: event.clientX, width: width.current, scale };
            }}
            onPointerMove={(event) => {
              if (!drag.current) return;
              take();
              // The panel is docked right: dragging its left edge left widens it.
              apply(drag.current.width + (drag.current.x - event.clientX) / drag.current.scale);
            }}
            onPointerUp={() => {
              drag.current = null;
            }}
            onPointerCancel={() => {
              drag.current = null;
            }}
            onKeyDown={(event) => {
              const step = event.shiftKey ? 64 : 16;
              const moves: Record<string, number> = {
                ArrowLeft: width.current + step,
                ArrowRight: width.current - step,
                Home: bounds.current.min,
                End: bounds.current.max,
              };
              if (!(event.key in moves)) return;
              event.preventDefault();
              take();
              cancelAnimationFrame(frame.current);
              apply(moves[event.key]);
            }}
          >
            <span className={styles.grip} aria-hidden="true" />
          </div>
          {list}
        </div>
      </div>

      <div className={styles.controls}>
        <div className={styles.presets} role="group" aria-label="Panel width">
          {PRESETS.map((preset) => (
            <button
              key={preset.layout}
              type="button"
              aria-pressed={shown.layout === preset.layout}
              disabled={preset.width > room && preset.layout !== 'compact'}
              onClick={() => {
                take();
                glide(preset.width, 700);
              }}
            >
              {preset.label}
            </button>
          ))}
        </div>
        <p className={styles.readout}>
          <span>{shown.width}px</span> · {shown.layout}
        </p>
      </div>
    </div>
  );
}
```

### adaptive-list/playground.module.css

```css
/* The adaptive list's playground (playground.tsx): a light stage with the list in a panel docked
   to its right edge, resized from the panel's left edge; at night, a near-black stage and a dark
   grey panel. */

.root {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root
     inherits from the page, or the one the colorScheme prop sets (below). The list inside
     inherits the same scheme. */
  /* The stage, then the panel a step up. */
  --surface: #efeff1;
  --surface-2: #fcfcfc;
  /* The width shown and a width under the pointer; the other widths; the read-out's words. */
  --ink: #111;
  --ink-muted: #6e6e76;
  --ink-faint: #8a8a92;
  /* The panel's edge, the stage's edge and dots; the widths' track and its edge. */
  --line: #e3e3e6;
  --edge: rgb(0 0 0 / 0.04);
  --dots: rgb(0 0 0 / 0.045);
  --track: rgb(0 0 0 / 0.05);
  --track-line: rgb(0 0 0 / 0.06);
  /* The chosen width and its text. */
  --accent: #111;
  --accent-ink: #fff;
  /* The resize grip, then held or focused, with its glow; the focus ring. */
  --grip: #c7c7cc;
  --focus: #0a84ff;
  --focus-glow: rgb(10 132 255 / 0.25);
  /* The panel's shade right under it and its soft drop. */
  --shadow-near: rgb(0 0 0 / 0.04);
  --shadow: rgb(0 0 0 / 0.08);
  min-width: 0;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#efeff1, #111113);
    --surface-2: light-dark(#fcfcfc, #1c1c1e);
    --ink: light-dark(#111, #f5f5f7);
    --ink-muted: light-dark(#6e6e76, #a1a1a8);
    --ink-faint: light-dark(#8a8a92, #8e8e96);
    --line: light-dark(#e3e3e6, #2e2e31);
    --edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.05));
    --dots: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.045));
    --track: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --track-line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --accent: light-dark(#111, #f5f5f7);
    --accent-ink: light-dark(#fff, #111);
    --grip: light-dark(#c7c7cc, #6e6e73);
    --focus: light-dark(#0a84ff, #3594ff);
    --focus-glow: light-dark(rgb(10 132 255 / 0.25), rgb(53 148 255 / 0.3));
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5));
  }
}

/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.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;
}

.stage {
  position: relative;
  height: 720px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
}

.panel {
  position: absolute;
  top: 12px;
  right: 12px;
  bottom: 12px;
  width: var(--panel, 480px);
  border: 1px solid var(--line);
  border-radius: 16px;
  background: var(--surface-2);
  box-shadow:
    0 1px 2px var(--shadow-near),
    0 8px 24px -12px var(--shadow);
}

/* The panel's left edge: a wide, invisible grip with a pill that shows on hover and drag. */
.handle {
  position: absolute;
  z-index: 2;
  top: 0;
  bottom: 0;
  left: -9px;
  width: 18px;
  cursor: ew-resize;
  touch-action: none;
}
.grip {
  position: absolute;
  top: 50%;
  left: 7px;
  width: 4px;
  height: 36px;
  margin-top: -18px;
  border-radius: 999px;
  background: var(--grip);
  opacity: 0;
  transform: scaleY(0.7);
  transition:
    opacity 0.2s ease,
    transform 0.2s ease,
    background-color 0.2s ease;
}
.handle:hover .grip,
.handle:focus-visible .grip,
.handle:active .grip {
  opacity: 1;
  transform: none;
}
.handle:active .grip {
  background: var(--focus);
}
.handle:focus-visible {
  outline: none;
}
.handle:focus-visible .grip {
  background: var(--focus);
  box-shadow: 0 0 0 3px var(--focus-glow);
}
/* On touch screens the grip stays visible: there is no hover to find it. */
@media (hover: none) {
  .grip {
    opacity: 1;
    transform: none;
  }
}

.controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  margin-top: 14px;
}
.presets {
  display: inline-flex;
  padding: 3px;
  border-radius: 999px;
  background: var(--track);
  box-shadow: inset 0 0 0 1px var(--track-line);
}
.presets button {
  height: 32px;
  padding: 0 14px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  font:
    500 13px/1 Inter,
    sans-serif;
  color: var(--ink-muted);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.presets button:hover:not(:disabled, [aria-pressed='true']) {
  color: var(--ink);
}
.presets button[aria-pressed='true'] {
  background: var(--accent);
  color: var(--accent-ink);
}
.presets button:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}
.presets button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.readout {
  margin: 0;
  font:
    400 12px/1.4 ui-monospace,
    'SF Mono',
    Menlo,
    monospace;
  letter-spacing: 0.02em;
  color: var(--ink-faint);
}
.readout span {
  display: inline-block;
  min-width: 5ch;
  color: var(--ink);
  text-align: right;
}

@media (max-width: 760px) {
  .stage {
    height: 640px;
  }
}
```

### adaptive-list/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 };
}
```