# Pinned list

> Pin a place and its row lifts off and flies to the pinned section, while the rows around it make room.

A list of places in two sections, Pinned and All places. Pin one and its row rises above the others and flies up to the end of the pinned places, its pin turning yellow and standing up, while the rows it leaves close the gap and the ones it joins make room. Unpin it and it flies back to its place in the alphabet.

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

Category: Data.

## Anatomy

1. **Stage**: A dotted backdrop at least 460 px tall. The list’s type follows the component’s width, from 14 to 16.5 px, and every size inside is in em.
2. **Section label**: Pinned, then All places: each section is a region named by its heading.
3. **Row**: One place a row. In flight it rises above the others, grows to 103% and its shadow deepens, on a spring.
4. **Tile**: The place’s glyph on a soft square, in the muted ink.
5. **Name**: One line, cut with an ellipsis when it runs long.
6. **Details**: What the place is and its hours, muted, on one line.
7. **Pin, on**: A toggle button (`aria-pressed`): yellow, the pin upright. Pressing it pops (86%, 112%, then rest) and unpins.
8. **Pin, off**: Grey, the pin tilted 40°. Its target reaches half an em past the disc, for a finger.

## When to use

- A list people come back to, where a few items matter more than the rest: saved places, projects, channels.
- Up to a dozen items, few enough that each one fits as a row with a name and a line of detail.
- Pinning that should feel physical: the row flies to its new section, so the reader sees where it went.
- Where order matters at the top (pinned items stay in the order they were pinned) and at the bottom (the rest stay alphabetical).

## When not to use

- Hundreds of items, or items that arrive while the reader looks. Use a searchable, virtualised list with a pinned filter.
- Ordering by hand, beyond pinned or not. Use a sortable list with drag handles.
- Choosing one item to act on now. Use a radio group, a menu or a select.
- Marking many items at once for a bulk action. Use checkboxes with an action bar.
- A favourite that changes nothing about the order. A star toggle on the row is enough.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/pinned-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 { PinnedList } from './pinned-list';
import { PLACES } from './places';

export function SavedPlaces() {
  return (
    <PinnedList
      places={PLACES}
      defaultPinned={['juniper']}
      autoplay={false}
      onPinnedChange={(ids) => localStorage.setItem('pinned', JSON.stringify(ids))}
    />
  );
}
```

## States

- **One pinned**: The default: one place up top, the rest below by name.
- **Several pinned**: Pinned places keep the order they were pinned in; a new one joins at the end.
- **Nothing pinned**: A dashed hint holds a row’s room, fading in once the last pinned row has flown away.
- **Pin under the pointer**: The disc and the pin darken a step, grey or yellow.
- **Pressed**: The pin shrinks to 88%. On release the row flies to its section: the flight itself can’t be held still.
- **Dark**: With `colorScheme="dark"`, or a dark page: a near-black stage and dark grey rows.

## How it works

- FLIP: before a pin changes, every row’s and section label’s place on screen is read. React then moves the row into its new section, and each row plays from its old place to its new one, so the gap closes and room opens.
- The flying row rides above the others on a softer spring, a touch larger and with a deeper shadow at mid-flight. Its pin changes colour and stands up with a little pop.
- Pinned places keep the order you pinned them in. Unpinned ones go back to their alphabetical place in All places.
- Positions are read as they are on screen, mid-flight included, so a change that interrupts another starts every row from where it is.
- When nothing is pinned, a dashed hint marks where the first place will land. It fades in once the last row has flown away.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the pin’s pop animates between colours read from the computed style, so it follows the scheme too.

## API reference

- **places** (`Place[]`, default `PLACES`): The places, in any order: an id, a name, a category (“Café”), its hours (“Closes 6 PM”) and a 20 × 20 icon.
- **defaultPinned** (`string[]`, default `['juniper']`): The ids of the places pinned at first, in order.
- **defaultPointer** (`{ id: string; pressed?: boolean }`): Draws a place’s pin as under the pointer, or pressed, until the reader’s first press. Turns the demo off.
- **onPinnedChange** (`(pinned: string[]) => void`): Called with the pinned ids, in order, after each change.
- **autoplay** (`boolean`, default `true`): Pins and unpins a few places with a demo cursor while on screen and untouched.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- Each pin is a toggle button (aria-pressed) labelled with its place (“Pin Saltmarsh Kitchen”). The two sections are lists labelled by their headings.
- Focus stays on the pin while its row moves to the other section, so Tab carries on from there.
- Pins and unpins are announced in a polite live region. The pin glyph is dark on yellow, for contrast.
- Under reduced motion rows change places at once, and there is no demo.

## Source

### pinned-list/pinned-list.tsx

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

'use client';

import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { DemoCursor, glideTo, hideCursor, pressWith } from './demo-cursor';
import { PinIcon } from './icons';
import { PLACES, type Place } from './places';
import { spring, timing } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './pinned-list.module.css';

/*
 * Pinned list: places in two sections, Pinned and All places. Pin a place and its row lifts off
 * and flies up to the end of the pinned ones, while the rows it leaves close the gap and the rows
 * it joins make room; unpin it and it flies back to its place in the alphabet.
 *
 * - FLIP: before a pin changes, the place of every row and section label on screen is read; React
 *   moves the row to its new section; then each one plays from its old place to its new one on a
 *   spring (spring.ts). The moving row rises above the others, a little larger, with a deeper
 *   shadow. A change that interrupts another starts from where each row is on screen.
 * - The pin turns yellow and stands up, with a pop.
 * - Keyboard: each pin is a toggle button, and focus stays on it while its row moves. Changes are
 *   announced to screen readers.
 * - While on screen and untouched, a demo cursor pins and unpins a few places (demo-cursor.tsx).
 * - Under reduced motion, rows change places at once.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets. The pin's change of colour animates
 *   between the colours the CSS resolves, read before and after.
 */

/** A change of pin, waiting for React to move the row to animate it. */
type Change = {
  id: string;
  /** Where each row and label was on screen, by its data-flip key. */
  before: Map<string, DOMRect>;
  /** The pin button's colours and its glyph's tilt, before. */
  pin: { fill: string; ink: string; tilt: string } | null;
  /** The reader's focus was on the pin: it stays there. */
  focus: boolean;
};

/** The row that flies to the other section. */
const FLY = spring(240, 25);
/** The rows that make room, or close the gap. */
const SHIFT = spring(300, 29);
/** The pin, standing up or lying down. */
const POP = spring(420, 20);
/** How long the demo's pointer rests on a pin before pressing it, then holds the press, in ms. */
const HOVER = 260;
const PRESS = 140;
/** The most places the demo pins before it starts unpinning them. */
const DEMO_MOST = 3;

const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const byName = (a: Place, b: Place) => a.name.localeCompare(b.name);

export function PinnedList({
  places = PLACES,
  defaultPinned = ['juniper'],
  onPinnedChange,
  autoplay = true,
  defaultPointer,
  colorScheme,
  className = '',
}: {
  /** The places, in any order: All places lists them by name. */
  places?: Place[];
  /** The ids of the places pinned at first, in order. */
  defaultPinned?: string[];
  /** Called with the pinned ids, in order, after each change. */
  onPinnedChange?: (pinned: string[]) => void;
  /** Pins and unpins a few places on its own while on screen and untouched. */
  autoplay?: boolean;
  /**
   * Draws a pin as under the pointer, or pressed, by its place's id: the demo is then off. The
   * reader's first press clears it.
   */
  defaultPointer?: { id: string; pressed?: boolean };
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const list = useRef<HTMLDivElement>(null);
  const cursor = useRef<HTMLSpanElement>(null);
  const [pinned, setPinned] = useState(defaultPinned);
  /** Where the demo's pointer is: a place's pin, pressed or not. */
  const [ghost, setGhost] = useState<{ id: string; pressed: boolean } | null>(() =>
    defaultPointer ? { id: defaultPointer.id, pressed: !!defaultPointer.pressed } : null,
  );
  const [message, setMessage] = useState('');
  const pending = useRef<Change | null>(null);
  const pinnedLabel = useId();
  const allLabel = useId();

  const pinnedPlaces = pinned.flatMap((id) => places.filter((place) => place.id === id));
  const others = places.filter((place) => !pinned.includes(place.id)).sort(byName);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  const toggle = (place: Place, byReader: boolean) => {
    const box = list.current;
    if (!box) return;
    // First: where everything is now, mid-flight included.
    const before = new Map<string, DOMRect>();
    for (const element of box.querySelectorAll<HTMLElement>('[data-flip]')) {
      before.set(element.dataset.flip ?? '', element.getBoundingClientRect());
    }
    const button = box.querySelector<HTMLElement>(`[data-pin="${place.id}"]`);
    const glyph = button?.querySelector('svg');
    pending.current = {
      id: place.id,
      before,
      pin:
        button && glyph
          ? {
              fill: getComputedStyle(button).backgroundColor,
              ink: getComputedStyle(button).color,
              tilt: getComputedStyle(glyph).transform,
            }
          : null,
      focus: byReader && !!button && document.activeElement === button,
    };
    const on = !pinned.includes(place.id);
    const next = on ? [...pinned, place.id] : pinned.filter((id) => id !== place.id);
    setPinned(next);
    onPinnedChange?.(next);
    if (byReader) {
      // A pointer drawn by defaultPointer gives way to the reader's.
      setGhost(null);
      announce(on ? `${place.name} pinned.` : `${place.name} unpinned.`);
    }
  };

  // Last, invert, play: each row and label from where it was to where it is now.
  useLayoutEffect(() => {
    const change = pending.current;
    const box = list.current;
    if (!change || !box) return;
    pending.current = null;
    const moved = box.querySelector<HTMLElement>(`[data-flip="${change.id}"]`);
    const button = moved?.querySelector<HTMLElement>('[data-pin]');
    // The row is a new element in its new section: focus goes with it.
    if (change.focus) button?.focus({ preventScroll: true });
    if (prefersReducedMotion()) return;

    // Screen pixels to the list's own, should it sit in a scaled container.
    const scale = box.getBoundingClientRect().width / box.offsetWidth || 1;
    for (const element of box.querySelectorAll<HTMLElement>('[data-flip]')) {
      const key = element.dataset.flip ?? '';
      const from = change.before.get(key);
      for (const running of element.getAnimations()) running.cancel();
      const to = element.getBoundingClientRect();
      if (!from) continue;
      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) continue;
      const flying = key === change.id;
      const flight = element.animate(
        [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
        timing(flying ? FLY : SHIFT),
      );
      if (!flying) continue;
      // The flying row rides above the others: lifted, a touch larger, its shadow deeper.
      element.dataset.lifted = '';
      flight.finished.then(
        () => delete element.dataset.lifted,
        () => {},
      );
      const lift = { duration: FLY.duration, easing: 'ease-in-out' };
      element
        .querySelector('[data-card]')
        ?.animate(
          [{ transform: 'none' }, { transform: 'scale(1.03)', offset: 0.3 }, { transform: 'none' }],
          lift,
        );
      element
        .querySelector('[data-shadow]')
        ?.animate([{ opacity: 0 }, { opacity: 1, offset: 0.3 }, { opacity: 0 }], lift);
    }

    // The pin changes colour and stands up (or lies down) with a pop.
    const glyph = button?.querySelector('svg');
    if (button && glyph && change.pin) {
      const now = getComputedStyle(button);
      const after = { backgroundColor: now.backgroundColor, color: now.color };
      button.animate(
        [
          { backgroundColor: change.pin.fill, color: change.pin.ink, transform: 'scale(0.86)' },
          { ...after, transform: 'scale(1.12)', offset: 0.45 },
          { ...after, transform: 'none' },
        ],
        { duration: 460, easing: 'ease-out' },
      );
      glyph.animate(
        [{ transform: change.pin.tilt }, { transform: getComputedStyle(glyph).transform }],
        timing(POP),
      );
    }
  }, [pinned]);

  // The demo runs across renders: it reads the latest lists and actions here.
  const latest = useRef({ pinnedPlaces, others, toggle });
  useLayoutEffect(() => {
    latest.current = { pinnedPlaces, others, toggle };
  });

  // The demo: pin places until DEMO_MOST are pinned (the last of All places, then the second),
  // then unpin the oldest until one is left, and again. While on screen and untouched.
  const touring = autoplay && !defaultPointer && playing;
  useEffect(() => {
    if (!touring) return;
    let alive = true;
    let timer = 0;
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        timer = window.setTimeout(resolve, ms);
      });
    (async () => {
      await wait(1000);
      let pinning = true;
      for (let turn = 0; alive; turn += 1) {
        const { pinnedPlaces: shown, others: rest } = latest.current;
        if (shown.length >= DEMO_MOST || !rest.length) pinning = false;
        if (shown.length <= 1) pinning = true;
        const place = pinning
          ? rest[turn % 2 ? Math.min(1, rest.length - 1) : rest.length - 1]
          : shown[0];
        if (!place) return;
        // The reader is in the list (it has their focus): the demo waits for them to leave it.
        if (root.current?.contains(document.activeElement)) {
          take();
          return;
        }
        // The cursor glides to the pin, rests on it, presses it.
        const pointer = cursor.current;
        const pin = list.current?.querySelector(`[data-pin="${place.id}"]`);
        if (!pointer || !pin) return;
        await wait(glideTo(pointer, pin));
        if (!alive) return;
        setGhost({ id: place.id, pressed: false });
        await wait(HOVER);
        if (!alive) return;
        setGhost({ id: place.id, pressed: true });
        pressWith(pointer, true);
        await wait(PRESS);
        if (!alive) return;
        setGhost(null);
        pressWith(pointer, false);
        latest.current.toggle(place, false);
        await wait(1400);
      }
    })();
    return () => {
      alive = false;
      window.clearTimeout(timer);
      setGhost(null);
      if (cursor.current) hideCursor(cursor.current);
    };
  }, [touring, root, take]);

  const row = (place: Place, isPinned: boolean) => (
    <li key={place.id} className={styles.row} data-flip={place.id}>
      <div className={styles.card} data-card data-part="row">
        <span className={styles.shadow} data-shadow aria-hidden="true" />
        <span className={styles.tile} data-part="tile" aria-hidden="true">
          {place.icon}
        </span>
        <span className={styles.text}>
          <span className={styles.name} data-part="name">
            {place.name}
          </span>
          <span className={styles.meta} data-part="meta">
            {place.category} · {place.hours}
          </span>
        </span>
        <button
          type="button"
          className={styles.pin}
          aria-pressed={isPinned}
          aria-label={`Pin ${place.name}`}
          data-pin={place.id}
          data-part="pin"
          data-ghost={ghost?.id === place.id ? (ghost.pressed ? 'pressed' : '') : undefined}
          onClick={() => toggle(place, true)}
        >
          <PinIcon />
        </button>
      </div>
    </li>
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocusCapture={take}
    >
      <div className={styles.stage} data-part="stage">
        <div ref={list} className={styles.list}>
          <section className={styles.section} aria-labelledby={pinnedLabel}>
            <h3 id={pinnedLabel} className={styles.label} data-part="label">
              Pinned
            </h3>
            <div className={styles.pinned} data-part="pinned">
              <ul className={styles.rows}>{pinnedPlaces.map((place) => row(place, true))}</ul>
              <p
                className={styles.empty}
                data-part="hint"
                data-shown={pinnedPlaces.length ? undefined : ''}
                aria-hidden={pinnedPlaces.length > 0}
              >
                <PinIcon className={styles.emptyPin} />
                Pinned places stay up here
              </p>
            </div>
          </section>
          <section className={styles.section} aria-labelledby={allLabel}>
            <h3 id={allLabel} className={styles.label} data-part="label" data-flip="all-places">
              All places
            </h3>
            <ul className={styles.rows}>{others.map((place) => row(place, false))}</ul>
          </section>
        </div>
        <DemoCursor ref={cursor} />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### pinned-list/pinned-list.module.css

```css
/* Pinned list (pinned-list.tsx): a light stage with two sections of places, each place a white
   row, or at night a near-black stage with each place a dark grey row. Sizes are in em: the list's
   font-size follows the component's width (a container), from 14px on a phone to 16.5px.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). */

.root {
  /* The stage, then the rows a step up, and the tiles and unpinned pins on them. */
  --surface: #eaeaed;
  --surface-2: #fff;
  --wash: #f1f1f4;
  /* Names; categories, hours and the tiles' glyphs; the section labels and the hint, on the
     stage. */
  --ink: #1d1d1f;
  --ink-muted: #6e6e73;
  --stage-ink: #636366;
  /* The stage's edge, its dots, the rows' edges, and the hint's dashed outline. */
  --line: rgb(0 0 0 / 0.06);
  --dots: rgb(0 0 0 / 0.05);
  --card-line: rgb(0 0 0 / 0.04);
  --dash: #cfcfd4;
  /* A row's shade, and the deeper one of the row in flight. */
  --shadow: rgb(0 0 0 / 0.05);
  --shadow-lift: rgb(0 0 0 / 0.32);
  /* An unpinned pin, then under the pointer. */
  --control: #ededf0;
  --control-ink: #76767b;
  --control-hover: #e2e2e6;
  --control-hover-ink: #636366;
  /* A pinned pin: yellow, then under the pointer; the pin on it, dark on both to read clearly. */
  --accent: #f6c000;
  --accent-hover: #ebb700;
  --accent-ink: #4d3a00;
  --focus: #0a84ff;
  /* The demo's cursor looks like the system's on both: a dark arrow with a white edge. Its press
     ring is dark on the light stage and light on the dark one. */
  --pointer: #1d1d1f;
  --pointer-edge: #fff;
  --pointer-shade: rgb(0 0 0 / 0.2);
  --pointer-shade-edge: rgb(0 0 0 / 0.1);
  --pointer-ring: rgb(0 0 0 / 0.14);
  container-type: inline-size;
  min-width: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#eaeaed, #111113);
    --surface-2: light-dark(#fff, #1c1c1e);
    --wash: light-dark(#f1f1f4, #2a2a2d);
    --ink: light-dark(#1d1d1f, #f5f5f7);
    --ink-muted: light-dark(#6e6e73, #98989d);
    --stage-ink: light-dark(#636366, #8e8e93);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --dots: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --card-line: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.06));
    --dash: light-dark(#cfcfd4, #3c3c40);
    --shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.35));
    --shadow-lift: light-dark(rgb(0 0 0 / 0.32), rgb(0 0 0 / 0.7));
    --control: light-dark(#ededf0, #2c2c2e);
    --control-ink: light-dark(#76767b, #98989d);
    --control-hover: light-dark(#e2e2e6, #3a3a3c);
    --control-hover-ink: light-dark(#636366, #b4b4b9);
    --accent: light-dark(#f6c000, #ebb800);
    --accent-hover: light-dark(#ebb700, #f7c51c);
    --focus: light-dark(#0a84ff, #3594ff);
    --pointer-shade: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.45));
    --pointer-shade-edge: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.25));
    --pointer-ring: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.2));
  }
}
/* 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;
  display: grid;
  place-items: center;
  min-height: max(460px, 58cqi);
  padding: 32px 16px;
  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(--line);
  font-size: clamp(14px, 11.5px + 0.55cqi, 16.5px);
  line-height: 1.3;
}

.list {
  width: min(100%, 24em);
}
.section + .section {
  margin-top: 1.15em;
}
.label {
  margin: 0 0 0.6em 0.4em;
  font-size: 0.8em;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--stage-ink);
}

.rows {
  display: flex;
  flex-direction: column;
  gap: 0.45em;
  margin: 0;
  padding: 0;
  list-style: none;
}
/* The pinned section keeps a row's room when empty, for the hint. */
.pinned {
  position: relative;
  min-height: 3.6em;
}
.row {
  position: relative;
}
/* The row flying to the other section passes over the others. */
.row[data-lifted] {
  z-index: 1;
}

.card {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.75em;
  height: 3.6em;
  padding: 0 0.7em 0 0.55em;
  border-radius: 1em;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--card-line),
    0 1px 2px var(--shadow);
}
/* The flying row's deeper shadow, faded in and out around the flight. */
.shadow {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 16px 32px -14px var(--shadow-lift);
  opacity: 0;
  pointer-events: none;
}
.tile {
  display: grid;
  flex: none;
  place-items: center;
  width: 2.5em;
  height: 2.5em;
  border-radius: 0.75em;
  background: var(--wash);
  color: var(--ink-muted);
}
.tile svg {
  width: 1.3em;
  height: 1.3em;
}
.text {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 0.15em;
  min-width: 0;
}
.name,
.meta {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.name {
  font-size: 0.97em;
  font-weight: 600;
}
.meta {
  font-size: 0.8em;
  color: var(--ink-muted);
}

/* The pin: grey and tilted while the place is unpinned, yellow and upright when pinned. */
.pin {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: 2.2em;
  height: 2.2em;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--control);
  color: var(--control-ink);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease,
    transform 0.2s ease;
}
/* A finger-sized target round the small disc. */
.pin::after {
  content: '';
  position: absolute;
  inset: -0.5em;
  border-radius: 50%;
}
.pin svg {
  width: 1.15em;
  height: 1.15em;
  transform: rotate(40deg);
}
.pin[aria-pressed='true'] {
  background: var(--accent);
  color: var(--accent-ink);
}
.pin[aria-pressed='true'] svg {
  transform: none;
}
@media (hover: hover) {
  .pin:hover {
    background: var(--control-hover);
    color: var(--control-hover-ink);
  }
  .pin[aria-pressed='true']:hover {
    background: var(--accent-hover);
    color: var(--accent-ink);
  }
}
.pin[data-ghost] {
  background: var(--control-hover);
  color: var(--control-hover-ink);
}
.pin[aria-pressed='true'][data-ghost] {
  background: var(--accent-hover);
  color: var(--accent-ink);
}
.pin:active,
.pin[data-ghost='pressed'] {
  transform: scale(0.88);
}
.pin:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* No place pinned: a hint where the first will go. It shows once the last one has flown away. */
.empty {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.45em;
  margin: 0;
  border: 1.5px dashed var(--dash);
  /* A row's radius, at this smaller size. */
  border-radius: calc(1em / 0.85);
  font-size: 0.85em;
  color: var(--stage-ink);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.empty[data-shown] {
  opacity: 1;
  transition-delay: 0.3s;
}
.emptyPin {
  width: 1.2em;
  height: 1.2em;
  transform: rotate(40deg);
}

/* For screen readers only: the announcements. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .pin,
  .empty,
  .empty[data-shown] {
    transition: none;
  }
}
```

### pinned-list/places.tsx

```tsx
import type { ReactNode } from 'react';
import { BoltIcon, BookIcon, CupIcon, CutleryIcon, PillIcon, SailIcon } from './icons';

/* The sample places: made up, as are their hours. */

export type Place = {
  id: string;
  name: string;
  /** What it is, shown before its hours: "Café". */
  category: string;
  /** Its hours, as shown: "Closes 6 PM". */
  hours: string;
  /** A 20 × 20 glyph in `currentColor`. */
  icon: ReactNode;
};

export const PLACES: Place[] = [
  {
    id: 'juniper',
    name: 'Juniper & Rye',
    category: 'Café',
    hours: 'Closes 6 PM',
    icon: <CupIcon />,
  },
  {
    id: 'fernhill',
    name: 'Fernhill Library',
    category: 'Library',
    hours: 'Closes 8 PM',
    icon: <BookIcon />,
  },
  {
    id: 'linden',
    name: 'Linden Pharmacy',
    category: 'Pharmacy',
    hours: 'Closes 10 PM',
    icon: <PillIcon />,
  },
  {
    id: 'northgate',
    name: 'Northgate Charging',
    category: 'EV charging',
    hours: 'Open 24 hours',
    icon: <BoltIcon />,
  },
  {
    id: 'pier-nine',
    name: 'Pier Nine Marina',
    category: 'Marina',
    hours: 'Closes 7 PM',
    icon: <SailIcon />,
  },
  {
    id: 'saltmarsh',
    name: 'Saltmarsh Kitchen',
    category: 'Restaurant',
    hours: 'Closes 11 PM',
    icon: <CutleryIcon />,
  },
];
```

### pinned-list/icons.tsx

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

/*
 * The pinned list's icons, drawn on a 20 × 20 grid with 1.6px strokes in `currentColor`: one glyph
 * per kind of place, and the pin.
 */

type IconProps = SVGProps<SVGSVGElement>;

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

export function CupIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M4 8.5h9.5v3.25a4.25 4.25 0 0 1-4.25 4.25h-1A4.25 4.25 0 0 1 4 11.75z" />
      <path d="M13.5 9.5h1.25a2 2 0 0 1 0 4H13.3" />
      <path d="M7.25 3.5c0 1.1 1 1.4 1 2.5M10.25 3.5c0 1.1 1 1.4 1 2.5" />
    </Icon>
  );
}

export function CutleryIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M5.5 3v4.25a2 2 0 0 0 4 0V3M7.5 3v14" />
      <path d="M14.5 17V3c-1.6 1.1-2.5 3.6-2.5 6.5V11h2.5" />
    </Icon>
  );
}

export function SailIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M10 3v10.5M10 4.5l5 8H10M10 7.5l-4 5h4" />
      <path d="M3.5 14.5h13l-1.75 2.5H5.25z" />
    </Icon>
  );
}

export function BoltIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M11.25 2.75 5.5 11h4.25l-1 6.25L14.5 9h-4.25z" />
    </Icon>
  );
}

export function PillIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <rect x="3.6" y="7.25" width="12.8" height="5.5" rx="2.75" transform="rotate(-45 10 10)" />
      <path d="m8.05 8.05 3.9 3.9" />
    </Icon>
  );
}

export function BookIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M4.5 4.75A1.75 1.75 0 0 1 6.25 3H15.5v11.5H6.25a1.75 1.75 0 0 0-1.75 1.75z" />
      <path d="M4.5 16.25A1.75 1.75 0 0 0 6.25 18h9.25M8 6.5h4.5" />
    </Icon>
  );
}

/** A push pin, upright: the component tilts it while its place is unpinned. */
export function PinIcon(props: IconProps) {
  return (
    <Icon strokeWidth="1.5" {...props}>
      <path
        fill="currentColor"
        d="M7.4 2.9h5.2l-.9 1.15v3.6l2.55 2.55v1.3H5.75V10.2L8.3 7.65v-3.6z"
      />
      <path d="M10 11.5v5.6" />
    </Icon>
  );
}
```

### pinned-list/spring.ts

```tsx
/*
 * Springs for the Web Animations API. A damped spring released from 0 towards 1 is simulated
 * once, then sampled into a linear() easing: played over the time it takes to settle, it
 * overshoots and settles like the spring, and the browser runs it like any other curve.
 */

export type Spring = {
  /** The spring's motion, as a CSS easing. */
  easing: string;
  /** How long it takes to settle, in ms. */
  duration: number;
};

/** For browsers without linear(): a curve that overshoots a touch, then settles. */
const FALLBACK = 'cubic-bezier(0.25, 1.15, 0.4, 1)';

/**
 * A spring of unit mass: `stiffness` pulls it towards its target, `damping` holds it back. The
 * lower the damping, the more it overshoots.
 */
export function spring(stiffness: number, damping: number): Spring {
  const step = 1 / 1000;
  const positions = [0];
  let position = 0;
  let velocity = 0;
  // One millisecond at a time, until it rests within a thousandth of the way (two seconds at most).
  while (positions.length < 2000) {
    velocity += (stiffness * (1 - position) - damping * velocity) * step;
    position += velocity * step;
    positions.push(position);
    if (Math.abs(1 - position) < 0.001 && Math.abs(velocity) < 0.01) break;
  }
  const duration = positions.length - 1;
  // About one stop per frame, evenly spaced, ending on the target.
  const stops = Math.ceil(duration / 16) + 1;
  const values = Array.from({ length: stops }, (_, i) =>
    i === stops - 1 ? 1 : Number(positions[Math.round((i / (stops - 1)) * duration)].toFixed(4)),
  );
  return { easing: `linear(${values.join(', ')})`, duration };
}

let linearSupported: boolean | undefined;

/** Options for element.animate() that play `motion`, plus any of your own. */
export function timing(
  motion: Spring,
  options: KeyframeAnimationOptions = {},
): KeyframeAnimationOptions {
  linearSupported ??= CSS.supports('animation-timing-function', 'linear(0, 1)');
  return {
    duration: motion.duration,
    easing: linearSupported ? motion.easing : FALLBACK,
    ...options,
  };
}
```

### pinned-list/demo-cursor.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './demo-cursor.module.css';

/*
 * The demo's pointer: while the demo plays on its own, an arrow glides to what it is about to
 * press and dips as it presses, so the demo reads like someone using the component. It sits in
 * the stage (its offset parent). Each glide is a CSS transition, the two axes on different
 * easings so that the path bends a little: nothing runs between moves.
 */

const ARROW = 'M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z';

export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true">
      <span className={styles.vertical}>
        <span className={styles.ring} />
        <svg className={styles.arrow} viewBox="0 0 20 20">
          {/* Its shadow: the arrow again, offset and faint (a CSS filter would cost a large
              GPU surface while the cursor moves). */}
          <path className={styles.shade} d={ARROW} transform="translate(0.3 1.1)" />
          <path className={styles.body} d={ARROW} />
        </svg>
      </span>
    </span>
  );
}

/** Puts the arrow's tip at (x, y) in the stage's px, gliding there in `ms`. */
function place(cursor: HTMLElement, x: number, y: number, ms: number) {
  cursor.style.setProperty('--move', `${ms}ms`);
  cursor.style.setProperty('--x', `${x}px`);
  cursor.style.setProperty('--y', `${y}px`);
}

/**
 * Glides the cursor to the middle of `target` (it fades in nearby first, if hidden) and returns
 * how long the trip takes, in ms.
 */
export function glideTo(cursor: HTMLElement, target: Element) {
  const stage = cursor.offsetParent;
  if (!(stage instanceof HTMLElement)) return 0;
  // Screen px to the stage's own, should it sit in a scaled container.
  const box = stage.getBoundingClientRect();
  const scale = box.width / stage.offsetWidth || 1;
  const rect = target.getBoundingClientRect();
  const x = (rect.left + rect.width / 2 - box.left) / scale;
  const y = (rect.top + rect.height / 2 - box.top) / scale;
  if (cursor.dataset.state === 'hidden') {
    // It comes in from below and to the right, inside the stage.
    place(
      cursor,
      Math.min(x + 64, stage.offsetWidth - 24),
      Math.min(y + 72, stage.offsetHeight - 24),
      0,
    );
    cursor.getBoundingClientRect();
    cursor.dataset.state = 'shown';
  }
  const distance = Math.hypot(
    x - parseFloat(cursor.style.getPropertyValue('--x')),
    y - parseFloat(cursor.style.getPropertyValue('--y')),
  );
  // Already there: no trip. Otherwise, longer trips take a little longer.
  const ms = distance < 2 ? 0 : Math.round(Math.min(900, Math.max(380, distance * 2.2)));
  place(cursor, x, y, ms);
  return ms;
}

/** The arrow dips, and a ring spreads where it presses. */
export function pressWith(cursor: HTMLElement, down: boolean) {
  cursor.dataset.state = down ? 'pressed' : 'shown';
  if (!down) return;
  cursor.querySelector(`.${styles.ring}`)?.animate(
    [
      { transform: 'scale(0.2)', opacity: 0.55 },
      { transform: 'scale(1)', opacity: 0 },
    ],
    { duration: 520, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
  );
}

/** Fades the cursor away where it is. */
export function hideCursor(cursor: HTMLElement) {
  cursor.dataset.state = 'hidden';
}
```

### pinned-list/demo-cursor.module.css

```css
/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in the stage. The
   outer box carries the horizontal move and the inner one the vertical move, eased differently,
   which bends the path into a gentle arc. Its colours are the stage's tokens
   (pinned-list.module.css). */

.cursor {
  --x: 0px;
  --y: 0px;
  --move: 0ms;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  pointer-events: none;
  opacity: 0;
  transform: translateX(var(--x));
  transition:
    opacity 0.25s ease,
    transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);
}
.cursor[data-state='shown'],
.cursor[data-state='pressed'] {
  opacity: 1;
}
.vertical {
  display: block;
  transform: translateY(var(--y));
  transition: transform var(--move) cubic-bezier(0.3, 0.55, 0.25, 1);
}
.arrow {
  display: block;
  width: 22px;
  height: 22px;
  /* The tip, at (4, 2.5) of the drawing, on the point. */
  margin: -2.75px 0 0 -4.4px;
  overflow: visible;
  transform-origin: 4.4px 2.75px;
  transition: transform 0.12s ease;
}
.body {
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.shade {
  fill: var(--pointer-shade);
  stroke: var(--pointer-shade-edge);
  stroke-width: 2.4;
  stroke-linejoin: round;
}
.cursor[data-state='pressed'] .arrow {
  transform: scale(0.86);
}
/* A soft ring where it presses. */
.ring {
  position: absolute;
  top: -15px;
  left: -15px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--pointer-ring);
  opacity: 0;
}
```

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