← ui

carry

ui

take a thing up and put it down elsewhere: by a press, a finger or the keys

npx shadcn add @ag/carry
Already installed? Preview an update
npx shadcn add @ag/carry --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-04
  • New: the web's drag and drop, swift-utils' Ink carry twinned number for number (carry.notes.md): an item taken up in a lane (a list, a strip of columns) or across lanes (a board) by a mouse that moves 3 px, a finger held 280 ms or the keys (space, the arrows, space or return, esc), carried with life (grown 3.5% from under the press, two shadows, leaning into its speed), the others gliding apart on a critically damped spring, landing on a spring; refusals open no gap and fly home, a reason said when given; while the card is over a refused place it wears the bad tone and its reason under it, and let go there it lands home and leans toward where it was asked to go; near a lane's ends and a board's sides the edge drive scrolls under it; every step said to a screen reader. A press may carry a group (a selection: one card, the gap of all of them, landing together), a lane may give its extents (windowed rows), onShift moves what follows an item (a column's cells), declines refuses a carry before it lifts. Controlled: onMove hands the move over and the next render lands it. useCarry and CarryLayer; lib/carry-core is the math without the DOM. Esc mid-drag is a rung of key-router's Escape ladder.
Updated 2026-11-04 · release notes

shelf controls

uses key-router

used by drag-list, kanban, table

the demo above, verbatim

carry.demo.tsx
"use client"

import "@/registry/base-nova/ui/edge-fade"
import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import {
  CarryLayer,
  HOLDABLE,
  KEYED_LIFT,
  type Slot,
  useCarry,
} from "@/registry/base-nova/ui/carry"

// #region strip
// The engine alone, on a strip: column heads carried along a row (the `x` axis), as a
// table's columns are. Each head is as wide as its name, so the gap opens to the
// carried head's own width. `useCarry` gives the props; `CarryLayer` draws the head
// in flight.
const HEADS = ["item", "category", "cost", "paid", "on", "where", "who paid"]

const HEAD =
  "flex h-9 items-center rounded-lg bg-background px-3 text-[13px] font-medium ring-1 ring-foreground/10 ring-inset outline-none focus-visible:ring-2 focus-visible:ring-ring"

function Strip() {
  const [heads, setHeads] = React.useState(HEADS)
  const lanes = React.useMemo(() => [heads], [heads])
  const carry = useCarry<string>({
    lanes,
    axis: "x",
    name: (h) => h,
    onMove: (h, from: Slot, to: Slot) =>
      setHeads((all) => {
        const rest = all.filter((_, i) => i !== from.index)
        return [...rest.slice(0, to.index), h, ...rest.slice(to.index)]
      }),
  })
  return (
    <div className="ag-edge-fade-xy w-full overflow-x-auto rounded-xl bg-fill-group p-2">
      <ul ref={carry.lane(0)} aria-label="columns" className="flex w-max gap-2">
        {heads.map((h) => (
          <li
            key={h}
            {...carry.item(h)}
            className={cn(HEAD, HOLDABLE, KEYED_LIFT, "cursor-grab")}
          >
            {h}
          </li>
        ))}
      </ul>
      <CarryLayer
        carry={carry}
        className="rounded-lg"
        render={(h) => (
          <div className={cn(HEAD, "ring-foreground/18")}>{h}</div>
        )}
      />
    </div>
  )
}
// #endregion

export default function CarryDemo() {
  return (
    <Sample
      name="strip"
      glance="wide"
      title="a strip of columns"
      description="Carry a head sideways: the others glide apart to its own width, the gap crossing a neighbour only once the head is past its middle. By keys: Tab in, space, ← →, space. The engine under drag-list and kanban, used alone."
      with="strip"
    >
      <Strip />
    </Sample>
  )
}

the source, every file an install writes

carry.tsx
"use client"

// CARRY, bound to the page: an item taken up under a press, a finger or the keys,
// and put down elsewhere, in one lane (a list, a strip) or across lanes (a board).
// lib/carry-core holds the numbers and the math, Ink's; this is the hand and the DOM.
//
// The pointer: a mouse takes a card up once it moves 3 px (a press that never moves
// opens it); a finger once held 280 ms, so a stroke that starts at once still scrolls.
// The card is then drawn over everything where the press is, grown 3.5% from under it
// with a little overshoot, two shadows, leaning into its sideways speed (3° at 1400
// px/s, righting once still 60 ms); its slot holds open and the others glide apart
// where it would land, on one critically damped spring that carries its velocity
// through a retarget; the gap crosses a neighbour only 20% past that neighbour's
// middle. Near a lane's ends (and a board's sides) the edge drive scrolls under it.
// Let go, it lands in the gap on a spring; over a place `refuses` says no to, no gap
// opens anywhere and it flies home.
//
// The keys: space takes the focused card up, the arrows move it (along its lane, or
// to the next lane), space or return puts it down, esc puts it back; a refused step
// leans toward where it was asked to go and springs back. Without a card up, the
// arrows walk the focus and return opens. Every step is said to a screen reader.
//
// CONTROLLED. The board is the consumer's: a move is handed over (`onMove`) and the
// consumer's lanes say where things are. The card lands where the next render puts
// it; a move the consumer does not apply flies the card home.
//
// What a carry changes is split by how often it changes, as Ink's DragBoard splits it:
// where the card is moves every frame and is written to the elements by one loop,
// never rendered; where it would land (`target`) changes only when it crosses a
// neighbour and is React state; the geometry is read once at the lift, and nothing is
// read back mid-carry but the lanes' own boxes, which move with whatever scrolls them.

import { CircleAlert } from "lucide-react"
import * as React from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import {
  assert,
  CARRY,
  type CarryState,
  EdgeDriver,
  extraOf,
  footShiftOf,
  gapOf,
  type LaneLayout,
  placeAt,
  type Slot,
  Spring,
  sameSlot,
  shiftsOf,
  slotStart,
  stepFrom,
  tiltFor,
} from "@/registry/base-nova/lib/carry-core"
import { openLayer } from "@/registry/base-nova/ui/key-router"

export type { Slot } from "@/registry/base-nova/lib/carry-core"

/** Which way a lane's items stand: down a column (`y`, lanes side by side) or along
 *  a strip (`x`, a row of columns). */
export type CarryAxis = "y" | "x"

/** What a lane says while a card is carried: nothing, that the card would land in it,
 *  or that it refuses the card. */
export type CarryLight = "none" | "receiving" | "refusing"

export type CarryOptions<ID> = {
  /** The items' order now, lane by lane (one lane for a list). */
  lanes: readonly (readonly ID[])[]
  axis?: CarryAxis
  /** A move the board does not allow (a full lane, a rule): no gap opens there, the
   *  lane says no, and a card let go there flies home. A string is the refusal said
   *  ("sorted by cost: clear the sort to reorder"). */
  refuses?: (id: ID, from: Slot, to: Slot) => boolean | string
  /** A move made: the consumer applies it to its lanes. With a `group`, `id` is the
   *  pressed item and `snapshot().group` all that move, landing together at `to`. */
  onMove: (id: ID, from: Slot, to: Slot) => void
  /** A carry not allowed at all now, and why ("sorted by cost: clear the sort to
   *  reorder"): a press that travels takes nothing up, its item leans the way it was
   *  pulled and springs back, felt and said; a press that does not travel still opens. */
  declines?: (id: ID) => string | false
  /** What a press on `id` carries: it and what moves with it (a selection of rows), in
   *  one lane; `[id]` unless said. One card floats (the pressed item's), the gap is
   *  all of theirs, and they land together in their order. */
  group?: (id: ID) => readonly ID[]
  /** An item's extent along the lanes, when the lane draws its items at extents it
   *  knows (windowed rows of one height): nothing is measured, and items that are not
   *  mounted take part all the same. */
  extent?: (id: ID) => number
  /** Every frame an item's shift changes, with the px it is moved by: for what moves
   *  with the item but is not it (a column's cells with its head). */
  onShift?: (id: ID, px: number) => void
  /** What an item is called, said to a screen reader as it moves. */
  name: (id: ID) => string
  /** What a lane is called ("done"), said as a card enters it. */
  laneName?: (lane: number) => string
  /** A press that never moved, or return: the item opened. */
  onOpen?: (id: ID) => void
  /** Delete or backspace on the focused item. */
  onDelete?: (id: ID) => void
}

/** What React draws from: changes only when a card is taken up, crosses a neighbour,
 *  is refused or lands. */
export type CarrySnapshot<ID> = {
  carried: ID | null
  /** What is carried with it, itself included, in the lane's order. */
  group: readonly ID[]
  mode: "pointer" | "keys"
  source: Slot
  target: Slot
  refusing: number | null
  /** Why, while the carried card is over a place that refuses it with words: what
   *  the floating card says beside it. */
  reason: string | null
  settling: boolean
  /** Going home: every item back in its own place. */
  home: boolean
  /** Counts each refusal, and the lane that refused last: a count leans each time. */
  refusals: number
  refusedLane: number | null
  /** What is said to a screen reader now. */
  said: string
}

const IDLE: CarrySnapshot<never> = {
  carried: null,
  group: [],
  mode: "pointer",
  source: { lane: 0, index: 0 },
  target: { lane: 0, index: 0 },
  refusing: null,
  reason: null,
  settling: false,
  home: false,
  refusals: 0,
  refusedLane: null,
  said: "",
}

/** A lifted card's light, by the ground it stands on: a tight shadow where it nearly
 *  touches and a soft one well below it, more of both on a dark ground, which swallows
 *  shadow (Ink's DragLiftShadow). */
export const LIFTED =
  "shadow-[0_1px_3px_rgb(0_0_0/0.12),0_16px_44px_rgb(0_0_0/0.2)] dark:shadow-[0_1px_3px_rgb(0_0_0/0.35),0_16px_44px_rgb(0_0_0/0.5)]"

/** A carried card's look while the keys hold it up, in its gap: the same light. */
export const KEYED_LIFT =
  "data-[lifted]:shadow-[0_1px_3px_rgb(0_0_0/0.12),0_16px_44px_rgb(0_0_0/0.2)] dark:data-[lifted]:shadow-[0_1px_3px_rgb(0_0_0/0.35),0_16px_44px_rgb(0_0_0/0.5)]"

/** What an item wears so a finger can hold it: no callout, no text selection, no
 *  double-tap zoom (a scroll still passes). */
export const HOLDABLE =
  "touch-manipulation select-none [-webkit-touch-callout:none]"

/** Controls inside an item that take their own press (a card's own buttons). */
const OWN_PRESS =
  "button, a[href], input, textarea, select, [contenteditable], [data-carry-ignore]"

/** The press's feel on a touch screen, where a phone can (navigator.vibrate): a lift,
 *  a place crossed, a landing, and a refusal as two short pulses 70 ms apart (Ink's
 *  refusal on a Mac's trackpad). */
const FEEL = { lift: 8, tick: 4, confirm: 12, refuse: [8, 70, 8] } as const

type Press<ID> = {
  id: ID
  pointerId: number
  touch: boolean
  x: number
  y: number
  hold: number
}

/** The nearest element that scrolls `el` along `axis`, or the page. */
function scrollerOf(el: Element, axis: CarryAxis): HTMLElement {
  for (let p = el.parentElement; p; p = p.parentElement) {
    const s = getComputedStyle(p)
    const o = axis === "y" ? s.overflowY : s.overflowX
    if (o === "auto" || o === "scroll") return p
  }
  return (document.scrollingElement ?? document.documentElement) as HTMLElement
}

const isPage = (el: HTMLElement) =>
  el === document.scrollingElement || el === document.documentElement

/** A box along the lanes' axis (`main`) and across it (`cross`). */
function along(r: DOMRect, axis: CarryAxis) {
  return axis === "y"
    ? { start: r.top, size: r.height, cross: r.left, crossSize: r.width }
    : { start: r.left, size: r.width, cross: r.top, crossSize: r.height }
}

/** The engine of one board. Imperative: it holds the elements, the springs and the
 *  frame loop; React reads its snapshot. */
export class Carrier<ID> {
  opts: CarryOptions<ID>
  private snap: CarrySnapshot<ID> = IDLE as CarrySnapshot<ID>
  private listeners = new Set<() => void>()

  private items = new Map<ID, HTMLElement>()
  private laneEls: (HTMLElement | null)[] = []
  private laneScrollers: (HTMLElement | null)[] = []
  private feet: (HTMLElement | null)[] = []
  private rooms: (HTMLElement | null)[] = []
  private board: HTMLElement | null = null
  private floatEl: HTMLElement | null = null

  private layout: LaneLayout<ID>[] = []
  private shifts = new Map<ID, Spring>()
  private footShift: Spring[] = []
  private roomSize: Spring[] = []
  private float = {
    x: new Spring(0, CARRY.settle, 0.1),
    y: new Spring(0, CARRY.settle, 0.1),
    scale: new Spring(1, CARRY.lift, 0.0005),
    tilt: new Spring(0, CARRY.follow, 0.01),
    width: 0,
    anchor: { x: 0, y: 0 },
  }
  /** The card carried by keys: its offset to its slot, and its lift. */
  private keyed = {
    main: new Spring(0, CARRY.glide, 0.1),
    cross: new Spring(0, CARRY.glide, 0.1),
    scale: new Spring(1, CARRY.lift, 0.0005),
  }
  private frame = 0
  private press: Press<ID> | null = null
  private grab = { x: 0, y: 0 }
  private pointer = { x: 0, y: 0 }
  private speed = 0
  private moved = 0
  private righting = 0
  private hovered = 0
  private asked: { slot: Slot; refused: boolean | string } | null = null
  /** The refusal the gap is held by, said on the drop. */
  private reason: string | null = null
  /** The way the refused place lies from home, (dx, dy) in lane and index steps:
   *  a refused drop lands home and leans that way (Ink's `inkRefusal(.toward)`). */
  private refusedToward: [number, number] | null = null
  private leanOnLanding: [number, number] | null = null
  private touch = false
  /** A move handed to the consumer, waiting for its lanes to say so: `first` is the
   *  group's first item, which lands at `to`. */
  private committing: {
    id: ID
    first: ID
    to: Slot
    waited: number
  } | null = null
  private main = new EdgeDriver((px) => this.driveMain(px))
  private side = new EdgeDriver((px) => this.driveSide(px))
  private undoPage: (() => void) | null = null

  constructor(opts: CarryOptions<ID>) {
    this.opts = opts
  }

  get axis(): CarryAxis {
    return this.opts.axis ?? "y"
  }

  // ── React's side ──────────────────────────────────────────────────────────────

  subscribe = (f: () => void) => {
    this.listeners.add(f)
    return () => this.listeners.delete(f)
  }

  snapshot = () => this.snap

  private set(next: Partial<CarrySnapshot<ID>>): void {
    this.snap = { ...this.snap, ...next }
    for (const f of this.listeners) f()
  }

  item(id: ID, el: HTMLElement | null): void {
    if (!el) {
      if (this.items.get(id)) this.items.delete(id)
      return
    }
    this.items.set(id, el)
    // Mounted mid-carry (a windowed row the edge drive brought in): it stands where
    // the carry puts it at once, and hides if it is carried.
    const s = this.snap
    if (s.carried === null) return
    if (s.group.includes(id) && s.mode === "pointer") el.style.opacity = "0"
    this.paintItem(id)
  }

  lane(i: number, el: HTMLElement | null): void {
    this.laneEls[i] = el
  }

  /** A lane's own scroller; without one, the nearest that scrolls it (or the page). */
  laneScroller(i: number, el: HTMLElement | null): void {
    this.laneScrollers[i] = el
  }

  foot(i: number, el: HTMLElement | null): void {
    this.feet[i] = el
  }

  room(i: number, el: HTMLElement | null): void {
    this.rooms[i] = el
  }

  /** What scrolls a board of lanes across them (a kanban's sideways scroll). */
  across(el: HTMLElement | null): void {
    this.board = el
  }

  floatAt(el: HTMLElement | null): void {
    this.floatEl = el
    if (el) this.paint()
  }

  /** The lanes the consumer now draws: a move handed over has landed in them. */
  lanesChanged(): void {
    const c = this.committing
    if (!c) return
    // Only lanes that hold the move: a render of the board's own (its snapshot) is
    // not the consumer's answer.
    const at = this.locate(c.first)
    if (!at || !sameSlot(at, c.to)) return
    this.committing = null
    // The DOM is in the new order: every shift is now nothing, at once (a glide
    // would move items that the new layout already put in place).
    for (const s of this.shifts.values()) s.jump(0)
    for (const s of this.footShift) s.jump(0)
    for (const s of this.roomSize) s.jump(0)
    if (this.snap.mode === "keys") {
      this.keyed.main.jump(0)
      this.keyed.cross.jump(0)
      this.paint()
      this.finish(c.id)
      return
    }
    this.paint()
    this.landOn(c.id)
  }

  // ── The pointer ───────────────────────────────────────────────────────────────

  pointerDown = (id: ID, e: React.PointerEvent<HTMLElement>): void => {
    if (e.button !== 0 || this.snap.carried !== null || this.press) return
    const own = (e.target as Element).closest(OWN_PRESS)
    if (own && own !== e.currentTarget) return
    const touch = e.pointerType === "touch"
    this.touch = touch
    this.press = {
      id,
      pointerId: e.pointerId,
      touch,
      x: e.clientX,
      y: e.clientY,
      hold: touch ? window.setTimeout(() => this.held(), CARRY.holdMs) : 0,
    }
    window.addEventListener("pointermove", this.pointerMove)
    window.addEventListener("pointerup", this.pointerUp)
    window.addEventListener("pointercancel", this.pointerCancel)
  }

  /** A finger held still long enough: the card comes up under it. */
  private held(): void {
    const p = this.press
    if (!p || this.snap.carried !== null) return
    p.hold = 0
    if (this.declined(p.id, 0, 0)) return
    this.lift(p.id, p.x, p.y)
  }

  /** A carry `declines` refuses: the press ends taking nothing up, the item leans the
   *  way it was pulled (along the lane, by `dx`, `dy`) and springs back, felt and
   *  said. */
  private declined(id: ID, dx: number, dy: number): boolean {
    const why = this.opts.declines?.(id)
    if (!why) return false
    this.release()
    const along = this.axis === "y" ? dy : dx
    const sign = along < 0 ? -1 : 1
    refuse(
      this.items.get(id),
      this.axis === "x" ? sign : 0,
      this.axis === "y" ? sign : 0,
      CARRY.leanStep,
    )
    this.feel("refuse")
    this.set({ refusals: this.snap.refusals + 1, said: why })
    return true
  }

  private pointerMove = (e: PointerEvent): void => {
    const p = this.press
    if (!p || e.pointerId !== p.pointerId) return
    if (this.snap.carried === null) {
      const d = Math.hypot(e.clientX - p.x, e.clientY - p.y)
      if (p.touch) {
        // Moving before the hold is a scroll: the press is the page's.
        if (d > CARRY.holdSlop) this.release()
        return
      }
      if (d < CARRY.travel) return
      if (this.declined(p.id, e.clientX - p.x, e.clientY - p.y)) return
      this.lift(p.id, p.x, p.y)
    }
    if (this.snap.carried === p.id && this.snap.mode === "pointer")
      this.move(e.clientX, e.clientY)
  }

  private pointerUp = (e: PointerEvent): void => {
    const p = this.press
    if (!p || e.pointerId !== p.pointerId) return
    const lifted = this.snap.carried === p.id && this.snap.mode === "pointer"
    this.release()
    if (lifted) this.drop()
    // Never taken up: a press that opens.
    else this.opts.onOpen?.(p.id)
  }

  private pointerCancel = (e: PointerEvent): void => {
    const p = this.press
    if (!p || e.pointerId !== p.pointerId) return
    const lifted = this.snap.carried === p.id && this.snap.mode === "pointer"
    this.release()
    if (lifted) this.cancel()
  }

  private release(): void {
    if (this.press?.hold) window.clearTimeout(this.press.hold)
    this.press = null
    window.removeEventListener("pointermove", this.pointerMove)
    window.removeEventListener("pointerup", this.pointerUp)
    window.removeEventListener("pointercancel", this.pointerCancel)
  }

  /** While a press carries: no text is selected, the hand is closed, a finger's
   *  stroke never scrolls the page, and a scroll under the card moves its gap. */
  private holdPage(): void {
    const root = document.documentElement
    const was = { select: root.style.userSelect, cursor: root.style.cursor }
    root.style.userSelect = "none"
    root.style.cursor = "grabbing"
    const still = (e: TouchEvent) => e.preventDefault()
    const scrolled = () => this.retarget()
    // esc mid-drag puts the card back, the rest of that press carrying nothing: the
    // carry is a rung of the page's Escape ladder (key-router), the innermost while
    // it lasts, so the Esc is its alone.
    const closeLayer = openLayer({
      root: () => this.laneEls[this.snap.source.lane] ?? null,
      onEscape: () => {
        this.release()
        this.cancel()
      },
    })
    document.addEventListener("touchmove", still, { passive: false })
    window.addEventListener("scroll", scrolled, {
      capture: true,
      passive: true,
    })
    this.undoPage = () => {
      root.style.userSelect = was.select
      root.style.cursor = was.cursor
      document.removeEventListener("touchmove", still)
      window.removeEventListener("scroll", scrolled, { capture: true })
      closeLayer()
    }
  }

  private freePage(): void {
    this.undoPage?.()
    this.undoPage = null
  }

  // ── The layout, read once ─────────────────────────────────────────────────────

  private measure(): void {
    const axis = this.axis
    this.layout = this.opts.lanes.map((ids, i) => {
      const content = this.laneEls[i]
      assert(content, `lane ${i} has no element (lane(${i}) unset)`)
      const box = along(content.getBoundingClientRect(), axis)
      const extent = new Map<ID, number>()
      const starts: number[] = []
      const given = this.opts.extent
      for (const id of ids) {
        // Extents the lane knows (windowed rows): an item need not be mounted.
        if (given) {
          extent.set(id, given(id))
          continue
        }
        const el = this.items.get(id)
        assert(el, `item ${String(id)} of lane ${i} is not mounted`)
        const r = along(el.getBoundingClientRect(), axis)
        extent.set(id, r.size)
        starts.push(r.start - box.start)
      }
      const cs = getComputedStyle(content)
      // Where the first item stands and the room between two, as drawn; the CSS
      // padding and gap only for a lane with too few items to read them from.
      const pad =
        starts[0] ??
        Number.parseFloat(axis === "y" ? cs.paddingTop : cs.paddingLeft)
      const spacing =
        starts.length > 1
          ? (starts[1] as number) -
            (starts[0] as number) -
            (extent.get(ids[0] as ID) ?? 0)
          : Number.parseFloat(axis === "y" ? cs.rowGap : cs.columnGap) || 0
      return { ids, extent, pad, spacing }
    })
    this.footShift = this.opts.lanes.map(
      (_, i) => this.footShift[i] ?? new Spring(0, CARRY.glide, 0.1),
    )
    this.roomSize = this.opts.lanes.map(
      (_, i) => this.roomSize[i] ?? new Spring(0, CARRY.glide, 0.1),
    )
  }

  private locate(id: ID): Slot | null {
    for (const [lane, ids] of this.opts.lanes.entries()) {
      const index = ids.indexOf(id)
      if (index >= 0) return { lane, index }
    }
    return null
  }

  private state(): CarryState<ID> {
    const s = this.snap
    assert(s.carried !== null, "nothing is carried")
    const lane = this.layout[s.source.lane]
    assert(lane, `no lane ${s.source.lane}`)
    return {
      carried: s.carried,
      group: new Set(s.group),
      source: s.source,
      target: s.target,
      gap: gapOf(lane, s.group),
      refusing: s.refusing,
      settling: s.settling,
      home: s.home,
    }
  }

  /** A lane's box along the axis, where it stands now. */
  private laneBox(lane: number) {
    const el = this.laneEls[lane]
    assert(el, `lane ${lane} has no element`)
    return along(el.getBoundingClientRect(), this.axis)
  }

  /** The lane across the lanes at `cross`: the one it is in, else the nearest. */
  private laneAt(cross: number): number {
    let best = 0
    let far = Number.POSITIVE_INFINITY
    for (let i = 0; i < this.laneEls.length; i++) {
      if (!this.laneEls[i]) continue
      const b = this.laneBox(i)
      if (cross >= b.cross && cross <= b.cross + b.crossSize) return i
      const d = Math.abs(b.cross + b.crossSize / 2 - cross)
      if (d < far) {
        far = d
        best = i
      }
    }
    return best
  }

  // ── A press carrying ──────────────────────────────────────────────────────────

  /** What a press on `id` carries, in its lane's order, and where that group stands
   *  among the items that stay. */
  private groupOf(id: ID): { group: ID[]; source: Slot } | null {
    const at = this.locate(id)
    if (!at) return null
    const ids = this.opts.lanes[at.lane] ?? []
    const asked = new Set(this.opts.group?.(id) ?? [id])
    assert(asked.has(id), "a carried group holds the pressed item")
    const group = ids.filter((x) => asked.has(x))
    assert(
      group.length === asked.size,
      "a carried group is all in the pressed item's lane",
    )
    // Its place: the first of the group, counted among the items that stay.
    const first = ids.indexOf(group[0] as ID)
    return { group, source: { lane: at.lane, index: first } }
  }

  private lift(id: ID, x: number, y: number): void {
    const found = this.groupOf(id)
    const el = this.items.get(id)
    if (!found || !el || this.snap.settling) return
    const { group, source: slot } = found
    this.measure()
    const r = el.getBoundingClientRect()
    this.grab = { x: x - r.left, y: y - r.top }
    this.pointer = { x, y }
    this.float.width = r.width
    this.float.anchor = { x: this.grab.x, y: this.grab.y }
    this.float.x.jump(r.left)
    this.float.y.jump(r.top)
    this.float.tilt.jump(0)
    this.float.scale.jump(1)
    this.float.scale.to(CARRY.scale, CARRY.lift)
    this.speed = 0
    this.moved = performance.now()
    this.asked = null
    this.hovered = slot.lane
    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches
    this.main.reduced = reduced
    this.side.reduced = reduced
    for (const g of group) {
      const it = this.items.get(g)
      if (it) it.style.opacity = "0"
    }
    this.reason = null
    this.refusedToward = null
    this.leanOnLanding = null
    // A mouse moving 3 px may have begun a selection before the card came up.
    window.getSelection()?.removeAllRanges()
    this.holdPage()
    this.set({
      carried: id,
      group,
      mode: "pointer",
      source: slot,
      target: slot,
      refusing: null,
      reason: null,
      settling: false,
      home: false,
      said:
        group.length > 1
          ? `${group.length} picked up`
          : `${this.opts.name(id)}, picked up`,
    })
    this.feel("lift")
    this.kick()
  }

  /** The press moved: the card follows it exactly, the gap goes where the card's
   *  middle is, the edges drive. */
  private move(x: number, y: number): void {
    if (this.snap.settling) return
    this.lean(x)
    this.pointer = { x, y }
    this.float.x.jump(x - this.grab.x)
    this.float.y.jump(y - this.grab.y)
    this.retarget()
    const axis = this.axis
    const cross = axis === "y" ? x : y
    const lane = this.laneAt(cross)
    if (lane !== this.hovered) {
      this.main.end()
      this.hovered = lane
    }
    const scroller = this.scrollerOf(lane)
    const box = isPage(scroller)
      ? axis === "y"
        ? { lo: 0, hi: window.innerHeight }
        : { lo: 0, hi: window.innerWidth }
      : (() => {
          const b = along(scroller.getBoundingClientRect(), axis)
          return { lo: b.start, hi: b.start + b.size }
        })()
    this.main.follow(axis === "y" ? y : x, box)
    if (this.board) {
      const b = along(this.board.getBoundingClientRect(), axis)
      this.side.follow(cross, { lo: b.cross, hi: b.cross + b.crossSize })
    }
    this.kick()
  }

  private scrollerOf(lane: number): HTMLElement {
    const own = this.laneScrollers[lane]
    if (own) return own
    const el = this.laneEls[lane]
    assert(el, `lane ${lane} has no element`)
    return scrollerOf(el, this.axis)
  }

  /** The card leans into its sideways travel by the press's speed, smoothed over
   *  about three events so one jittery event never kicks it, and rights itself once
   *  the press stops. */
  private lean(x: number): void {
    const now = performance.now()
    const dt = (now - this.moved) / 1000
    this.moved = now
    if (dt > 0.001)
      this.speed += ((x - this.pointer.x) / dt - this.speed) * 0.35
    this.float.tilt.to(tiltFor(this.speed), CARRY.follow)
    window.clearTimeout(this.righting)
    this.righting = window.setTimeout(() => {
      if (this.snap.carried === null || this.snap.settling) return
      this.speed = 0
      this.float.tilt.to(0, CARRY.upright)
      this.kick()
    }, CARRY.stillMs)
  }

  /** Where the card's middle is the gap opens, unless that place refuses it: then
   *  the gap goes home and the lane says no. */
  private retarget(): void {
    const s = this.snap
    if (s.carried === null || s.mode !== "pointer" || s.settling) return
    const axis = this.axis
    const st = this.state()
    const lane = this.laneAt(axis === "y" ? this.pointer.x : this.pointer.y)
    const box = this.laneBox(lane)
    const origin = axis === "y" ? this.float.y.value : this.float.x.value
    // The floating card's middle: the pressed item's, which is the card drawn.
    const card = this.layout[s.source.lane]?.extent.get(s.carried) ?? st.gap
    const middle = origin + card / 2 - box.start
    const place = placeAt(this.layout, st, lane, middle)
    let refused: boolean | string
    if (this.asked && sameSlot(this.asked.slot, place)) {
      refused = this.asked.refused
    } else {
      refused =
        !sameSlot(place, s.source) &&
        (this.opts.refuses?.(s.carried, s.source, place) ?? false)
      this.asked = { slot: place, refused }
    }
    this.reason = typeof refused === "string" ? refused : null
    const next = refused ? s.source : place
    const refusing = refused ? lane : null
    if (
      sameSlot(next, s.target) &&
      refusing === s.refusing &&
      this.reason === s.reason
    )
      return
    const crossed = !sameSlot(next, s.target)
    // Where it was asked to go, for the lean a refused drop ends in.
    this.refusedToward = refused ? towardOf(s.source, place, this.axis) : null
    this.set({ target: next, refusing, reason: this.reason })
    this.reshift()
    if (crossed) this.feel("tick")
  }

  /** Every item's spring toward where the carry puts it now. */
  private reshift(spec = CARRY.glide): void {
    const s = this.snap
    if (s.carried === null) return
    const st = this.state()
    for (const [lane, ids] of this.opts.lanes.entries()) {
      const by = shiftsOf(this.layout, st, lane)
      for (const [index, id] of ids.entries()) {
        const v = by[index] ?? 0
        let spring = this.shifts.get(id)
        // A spring only for what moves: a lane of thousands holds a handful.
        if (!spring) {
          if (v === 0) continue
          spring = new Spring(0, spec, 0.1)
          this.shifts.set(id, spring)
        }
        spring.to(v, spec)
      }
      this.footShift[lane]?.to(footShiftOf(this.layout, st, lane), spec)
      this.roomSize[lane]?.to(extraOf(this.layout, st, lane), spec)
    }
    this.kick()
  }

  /** The press lifted: the move is handed over and the card lands where the next
   *  render puts it; over a lane that refuses it, home. */
  private drop(): void {
    const s = this.snap
    if (s.carried === null || s.settling) return
    this.endDrives()
    if (s.refusing !== null) {
      this.set({
        refusals: s.refusals + 1,
        refusedLane: s.refusing,
        said: this.reason ?? this.refusedSaid(s.refusing, s.carried),
      })
      this.feel("refuse")
      this.leanOnLanding = this.refusedToward
      this.goHome()
      return
    }
    this.feel("confirm")
    if (sameSlot(s.source, s.target)) this.goHome()
    else this.commit(s.carried, s.source, s.target)
  }

  /** The move to the consumer; its next render lands it (`lanesChanged`). A move it
   *  does not apply within two frames flies the card home. */
  private commit(id: ID, from: Slot, to: Slot): void {
    const group = this.snap.group
    const what = group.length > 1 ? `${group.length}` : this.opts.name(id)
    this.set({
      source: to,
      target: to,
      settling: true,
      said: `${what} put down, ${this.placeSaid(to)}`,
    })
    this.committing = { id, first: group[0] ?? id, to, waited: 0 }
    this.opts.onMove(id, from, to)
    const wait = () => {
      const c = this.committing
      if (!c || c.id !== id) return
      if (++c.waited < 3) return void requestAnimationFrame(wait)
      this.committing = null
      this.set({ source: from, target: from })
      this.goHome()
    }
    requestAnimationFrame(wait)
  }

  /** esc, or the press taken away: the card goes back where it was. */
  cancel(): void {
    const s = this.snap
    if (s.carried === null || s.settling) return
    this.endDrives()
    this.set({ said: `${this.opts.name(s.carried)} put back` })
    this.goHome()
  }

  private goHome(): void {
    const s = this.snap
    if (s.carried === null) return
    const id = s.carried
    this.set({
      target: s.source,
      refusing: null,
      settling: true,
      home: true,
    })
    this.reshift()
    if (s.mode === "keys") {
      this.keyed.main.to(0, CARRY.settle)
      this.keyed.cross.to(0, CARRY.settle)
      this.keyed.scale.to(1, CARRY.settle)
      this.when(
        () =>
          this.keyed.main.settled &&
          this.keyed.cross.settled &&
          this.keyed.scale.settled,
        () => this.finish(id),
      )
      return
    }
    this.landOn(id)
  }

  /** The floating card lands on the item's own place, its scale, lean and shadow
   *  settling with it, then the item shows again. */
  private landOn(id: ID): void {
    const el = this.items.get(id)
    if (!el) {
      this.finish(id)
      return
    }
    const r = el.getBoundingClientRect()
    this.float.x.to(r.left, CARRY.settle)
    this.float.y.to(r.top, CARRY.settle)
    this.float.scale.to(1, CARRY.settle)
    this.float.tilt.to(0, CARRY.settle)
    window.clearTimeout(this.righting)
    this.kick()
    this.when(
      () =>
        this.float.x.settled &&
        this.float.y.settled &&
        this.float.scale.settled,
      () => this.finish(id),
    )
  }

  private finish(id: ID): void {
    for (const g of new Set([id, ...this.snap.group])) {
      const el = this.items.get(g)
      if (el) el.style.opacity = ""
    }
    this.freePage()
    this.set({
      carried: null,
      group: [],
      settling: false,
      refusing: null,
      reason: null,
      home: false,
    })
    this.paint()
    // A refused drop, home now: the card leans toward where it was asked to go and
    // springs back, as a refused step by keys does.
    const toward = this.leanOnLanding
    this.leanOnLanding = null
    if (toward) refuse(this.items.get(id), toward[0], toward[1], CARRY.leanStep)
  }

  private endDrives(): void {
    this.main.end()
    this.side.end()
  }

  private driveMain(px: number): boolean {
    const scroller = this.scrollerOf(this.hovered)
    const key = this.axis === "y" ? "scrollTop" : "scrollLeft"
    const was = scroller[key]
    scroller[key] = was + px
    if (Math.abs(scroller[key] - was) < 0.01) return false
    this.retarget()
    return true
  }

  private driveSide(px: number): boolean {
    const b = this.board
    if (!b) return false
    const key = this.axis === "y" ? "scrollLeft" : "scrollTop"
    const was = b[key]
    b[key] = was + px
    if (Math.abs(b[key] - was) < 0.01) return false
    this.retarget()
    return true
  }

  // ── The keys ──────────────────────────────────────────────────────────────────

  keyDown = (id: ID, e: React.KeyboardEvent<HTMLElement>): void => {
    if (e.target !== e.currentTarget) return
    const s = this.snap
    const carrying = s.carried === id && s.mode === "keys"
    const arrow = this.arrow(e.key)
    if (e.key === " ") {
      e.preventDefault()
      if (carrying) this.put()
      else if (s.carried === null) this.pick(id)
      return
    }
    if (e.key === "Enter") {
      e.preventDefault()
      if (carrying) this.put()
      else if (s.carried === null) this.opts.onOpen?.(id)
      return
    }
    if (e.key === "Escape" && s.carried === id) {
      e.preventDefault()
      this.cancel()
      return
    }
    if ((e.key === "Delete" || e.key === "Backspace") && s.carried === null) {
      if (!this.opts.onDelete) return
      e.preventDefault()
      const next = this.successor(id)
      this.opts.onDelete(id)
      if (next !== null)
        requestAnimationFrame(() => this.items.get(next)?.focus())
      return
    }
    if (!arrow || e.metaKey || e.ctrlKey || e.altKey) return
    e.preventDefault()
    if (carrying) {
      this.step(arrow.lane, arrow.index)
      return
    }
    if (s.carried !== null) return
    const next = this.neighbour(id, arrow.lane, arrow.index)
    if (next !== null) this.items.get(next)?.focus()
  }

  /** An arrow as a step along the lane (`index`) or to the next lane (`lane`). */
  private arrow(key: string): { lane: number; index: number } | null {
    const y = this.axis === "y"
    switch (key) {
      case "ArrowUp":
        return y ? { lane: 0, index: -1 } : { lane: -1, index: 0 }
      case "ArrowDown":
        return y ? { lane: 0, index: 1 } : { lane: 1, index: 0 }
      case "ArrowLeft":
        return y ? { lane: -1, index: 0 } : { lane: 0, index: -1 }
      case "ArrowRight":
        return y ? { lane: 1, index: 0 } : { lane: 0, index: 1 }
      default:
        return null
    }
  }

  private neighbour(id: ID, dLane: number, dIndex: number): ID | null {
    const at = this.locate(id)
    if (!at) return null
    const all = this.opts.lanes
    if (dLane !== 0) {
      let lane = at.lane + dLane
      while (all[lane] && all[lane]?.length === 0) lane += dLane
      const ids = all[lane]
      if (!ids || ids.length === 0) return null
      return ids[Math.min(at.index, ids.length - 1)] ?? null
    }
    return all[at.lane]?.[at.index + dIndex] ?? null
  }

  private successor(id: ID): ID | null {
    const at = this.locate(id)
    if (!at) return null
    const ids = this.opts.lanes[at.lane] ?? []
    return ids[at.index + 1] ?? ids[at.index - 1] ?? null
  }

  /** Space on a focused item: it rises in its slot, carried by keys. */
  pick(id: ID): void {
    const slot = this.locate(id)
    if (!slot || this.snap.carried !== null) return
    this.measure()
    this.keyed.main.jump(0)
    this.keyed.cross.jump(0)
    this.keyed.scale.jump(1)
    this.keyed.scale.to(CARRY.scale, CARRY.lift)
    this.items.get(id)?.setAttribute("data-lifted", "")
    this.set({
      carried: id,
      group: [id],
      mode: "keys",
      source: slot,
      target: slot,
      refusing: null,
      settling: false,
      home: false,
      said: `${this.opts.name(id)}, picked up, ${this.placeSaid(slot, id)}. Arrows move it, space puts it down, escape puts it back`,
    })
    this.feel("lift")
    this.kick()
  }

  /** An arrow while carrying by keys: a place along the lane or the next lane; a
   *  place that refuses it is leaned toward and sprung back from. */
  private step(dLane: number, dIndex: number): void {
    const s = this.snap
    if (s.carried === null || s.settling) return
    const st = this.state()
    const next = stepFrom(this.layout, st, dLane, dIndex)
    if (sameSlot(next, s.target)) return
    const refused =
      !sameSlot(next, s.source) &&
      (this.opts.refuses?.(s.carried, s.source, next) ?? false)
    if (refused) {
      this.set({
        refusals: s.refusals + 1,
        refusedLane: next.lane,
        said:
          typeof refused === "string"
            ? refused
            : this.refusedSaid(next.lane, s.carried),
      })
      this.feel("refuse")
      refuse(
        this.items.get(s.carried),
        this.axis === "y" ? dLane : dIndex,
        this.axis === "y" ? dIndex : dLane,
        dLane !== 0 ? CARRY.leanCard : CARRY.leanStep,
      )
      return
    }
    this.set({ target: next, said: this.placeSaid(next) })
    this.reshift()
    // The card carried by keys stands lifted in its gap.
    const from = this.laneBox(s.source.lane)
    const to = this.laneBox(next.lane)
    // Its own place is its home slot's start: the items before it are the same.
    const group = new Set(s.group)
    const home = from.start + slotStart(this.layout, group, s.source)
    const there = to.start + slotStart(this.layout, group, next)
    this.keyed.main.to(there - home, CARRY.glide)
    this.keyed.cross.to(to.cross - from.cross, CARRY.glide)
    this.feel("tick")
    this.kick()
    requestAnimationFrame(() =>
      this.items
        .get(s.carried as ID)
        ?.scrollIntoView({ block: "nearest", inline: "nearest" }),
    )
  }

  /** Space or return while carrying by keys: it lands where it stands. */
  private put(): void {
    const s = this.snap
    if (s.carried === null || s.settling) return
    this.items.get(s.carried)?.removeAttribute("data-lifted")
    this.keyed.scale.to(1, CARRY.settle)
    this.feel("confirm")
    if (sameSlot(s.source, s.target)) {
      const id = s.carried
      this.set({
        said: `${this.opts.name(id)} put down, ${this.placeSaid(s.target)}`,
      })
      this.goHome()
      return
    }
    this.commit(s.carried, s.source, s.target)
  }

  // ── Said and felt ─────────────────────────────────────────────────────────────

  private laneSaid(lane: number): string {
    return this.opts.laneName?.(lane) ?? `lane ${lane + 1}`
  }

  /** A refusal, said: on a board the lane that says no, in one lane the place. */
  private refusedSaid(lane: number, id: ID): string {
    return this.opts.lanes.length > 1
      ? `${this.laneSaid(lane)} does not take ${this.opts.name(id)}`
      : `${this.opts.name(id)} cannot go there`
  }

  /** Where `carried` stands at `slot`, as said: its place among the lane's items with
   *  it among them. */
  private placeSaid(slot: Slot, carried = this.snap.carried): string {
    const ids = this.opts.lanes[slot.lane] ?? []
    const count = ids.filter((id) => id !== carried).length + 1
    const where = `${slot.index + 1} of ${count}`
    return this.opts.lanes.length > 1
      ? `${where} in ${this.laneSaid(slot.lane)}`
      : where
  }

  private feel(kind: keyof typeof FEEL): void {
    if (!this.touch || !("vibrate" in navigator)) return
    navigator.vibrate(FEEL[kind] as number | number[])
  }

  // ── The frame loop ────────────────────────────────────────────────────────────

  private waits: { done: () => boolean; then: () => void }[] = []
  private last = 0

  /** Runs `then` once `done` holds, checked each frame. */
  private when(done: () => boolean, then: () => void): void {
    this.waits.push({ done, then })
    this.kick()
  }

  private kick(): void {
    if (this.frame !== 0) return
    this.last = performance.now()
    this.frame = requestAnimationFrame(this.tick)
  }

  private tick = (now: number): void => {
    this.frame = 0
    const dt = Math.min((now - this.last) / 1000, 1 / 30)
    this.last = now
    let moving = false
    for (const s of this.springs()) if (s.step(dt)) moving = true
    this.paint()
    const ready = this.waits.filter((w) => w.done())
    this.waits = this.waits.filter((w) => !ready.includes(w))
    for (const w of ready) w.then()
    if (moving || this.waits.length > 0)
      this.frame = requestAnimationFrame(this.tick)
  }

  private *springs(): Iterable<Spring> {
    yield* this.shifts.values()
    yield* this.footShift
    yield* this.roomSize
    yield this.float.x
    yield this.float.y
    yield this.float.scale
    yield this.float.tilt
    yield this.keyed.main
    yield this.keyed.cross
    yield this.keyed.scale
  }

  /** Writes one item where the carry puts it: the card carried by keys lifted in its
   *  gap, any other by its shift (and `onShift`, for what moves with it). */
  private paintItem(id: ID): void {
    const y = this.axis === "y"
    const s = this.snap
    const el = this.items.get(id)
    if (s.carried === id && s.mode === "keys") {
      if (!el) return
      const main = this.keyed.main.value
      const cross = this.keyed.cross.value
      el.style.transform = `translate(${y ? cross : main}px, ${y ? main : cross}px) scale(${this.keyed.scale.value})`
      el.style.zIndex = "10"
      el.style.position ||= "relative"
      return
    }
    const v = this.shifts.get(id)?.value ?? 0
    this.opts.onShift?.(id, v)
    if (!el) return
    el.style.transform =
      v === 0 ? "" : y ? `translateY(${v}px)` : `translateX(${v}px)`
    if (el.style.zIndex === "10") el.style.zIndex = ""
  }

  /** Writes every spring to its element. */
  private paint(): void {
    const y = this.axis === "y"
    const s = this.snap
    for (const id of this.shifts.keys()) this.paintItem(id)
    if (s.carried !== null && !this.shifts.has(s.carried))
      this.paintItem(s.carried)
    for (const [i, spring] of this.footShift.entries()) {
      const el = this.feet[i]
      if (el)
        el.style.transform =
          spring.value === 0
            ? ""
            : y
              ? `translateY(${spring.value}px)`
              : `translateX(${spring.value}px)`
    }
    for (const [i, spring] of this.roomSize.entries()) {
      const el = this.rooms[i]
      if (el)
        el.style[y ? "height" : "width"] = `${Math.max(0, spring.value)}px`
    }
    const f = this.floatEl
    if (f) {
      const fl = this.float
      f.style.width = `${fl.width}px`
      f.style.transformOrigin = `${fl.anchor.x}px ${fl.anchor.y}px`
      f.style.transform = `translate(${fl.x.value}px, ${fl.y.value}px) rotate(${fl.tilt.value}deg) scale(${fl.scale.value})`
    }
  }
}

/** The way `to` lies from `from` on screen, one step each way at most: a lane is a
 *  step across the lanes, a place a step along one. */
function towardOf(from: Slot, to: Slot, axis: CarryAxis): [number, number] {
  const across = Math.sign(to.lane - from.lane)
  const along = across !== 0 ? 0 : Math.sign(to.index - from.index)
  return axis === "y" ? [across, along] : [along, across]
}

/** "Not now", seen: the element leans `points` toward (dx, dy), out in 0.09 s, home on
 *  a spring that overshoots once (Ink's InkRefusal), on its own `translate` so the
 *  carry's `transform` is untouched. */
export function refuse(
  el: Element | null | undefined,
  dx: number,
  dy: number,
  points: number,
): void {
  if (!el || matchMedia("(prefers-reduced-motion: reduce)").matches) return
  const at = (k: number) => `${dx * points * k}px ${dy * points * k}px`
  el.animate(
    [
      { translate: "0 0", offset: 0, easing: "cubic-bezier(0.2, 0, 0.4, 1)" },
      {
        translate: at(1),
        offset: 0.13,
        easing: "cubic-bezier(0.3, 1.6, 0.5, 1)",
      },
      { translate: "0 0", offset: 1 },
    ],
    // Added to the element's own translate (a table's row is placed by it), never in
    // place of it.
    { duration: 690, composite: "add" },
  )
}

export type Carry<ID> = {
  engine: Carrier<ID>
  state: CarrySnapshot<ID>
  /** The id of the text that tells a screen reader how to carry. */
  hint: string
  /** Every item's props: its element, the press, the keys, one Tab stop per board. */
  item: (id: ID) => {
    ref: (el: HTMLElement | null) => void
    tabIndex: number
    onPointerDown: (e: React.PointerEvent<HTMLElement>) => void
    onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void
    onFocus: () => void
    "aria-describedby": string
    "data-carried"?: ""
  }
  /** A lane's content: the element its items stand in, in order. */
  lane: (i: number) => (el: HTMLElement | null) => void
  /** A lane's own scroller, when it has one the lane is not inside of. */
  laneScroller: (i: number) => (el: HTMLElement | null) => void
  /** What follows a lane's last item (an add row): moved as the carried slot closes. */
  foot: (i: number) => (el: HTMLElement | null) => void
  /** A spacer after a lane's items: the room it gains as a card from another comes. */
  room: (i: number) => (el: HTMLElement | null) => void
  /** What scrolls a board across its lanes. */
  across: (el: HTMLElement | null) => void
  light: (lane: number) => CarryLight
}

/** A board's carry: one engine, its snapshot, and the props its parts wear. Draw a
 *  `CarryLayer` with it. */
export function useCarry<ID extends string | number>(
  opts: CarryOptions<ID>,
): Carry<ID> {
  const engine = React.useRef<Carrier<ID> | null>(null)
  engine.current ??= new Carrier(opts)
  const e = engine.current
  e.opts = opts
  const state = React.useSyncExternalStore(e.subscribe, e.snapshot, e.snapshot)
  const hint = React.useId()
  // The lanes the consumer draws after a move: the move landed. Run for every new
  // lanes, which the engine reads through `opts`, set above.
  // biome-ignore lint/correctness/useExhaustiveDependencies: the lanes are the trigger
  React.useLayoutEffect(() => {
    e.lanesChanged()
  }, [opts.lanes, e])
  const [focus, setFocus] = React.useState<ID | null>(null)
  const first = opts.lanes.find((l) => l.length > 0)?.[0] ?? null
  const stop =
    focus !== null && opts.lanes.some((l) => l.includes(focus)) ? focus : first
  return {
    engine: e,
    state,
    hint,
    item: (id) => ({
      ref: (el) => e.item(id, el),
      tabIndex: id === stop ? 0 : -1,
      onPointerDown: (ev) => e.pointerDown(id, ev),
      onKeyDown: (ev) => e.keyDown(id, ev),
      onFocus: () => setFocus(id),
      "aria-describedby": hint,
      ...(state.carried === id ? { "data-carried": "" as const } : {}),
    }),
    lane: (i) => (el) => e.lane(i, el),
    laneScroller: (i) => (el) => e.laneScroller(i, el),
    foot: (i) => (el) => e.foot(i, el),
    room: (i) => (el) => e.room(i, el),
    across: (el) => e.across(el),
    light: (lane) => {
      if (state.carried === null || state.settling) return "none"
      if (state.refusing === lane) return "refusing"
      return state.refusing === null && state.target.lane === lane
        ? "receiving"
        : "none"
    },
  }
}

/** The carried card drawn over everything where the press is, what is said to a
 *  screen reader, and how to carry. `render` draws the carried item's face (the
 *  item's own, as it stands in its lane); `className` its floating box. */
export function CarryLayer<ID extends string | number>({
  carry,
  render,
  className,
}: {
  carry: Carry<ID>
  render: (id: ID) => React.ReactNode
  className?: string
}) {
  const { state, engine, hint } = carry
  const [host, setHost] = React.useState<HTMLElement | null>(null)
  React.useEffect(() => setHost(document.body), [])
  const floating = state.carried !== null && state.mode === "pointer"
  return (
    <>
      <span id={hint} className="sr-only">
        space picks it up, the arrows move it, space puts it down, escape puts
        it back
      </span>
      <span aria-live="polite" className="sr-only">
        {state.said}
      </span>
      {host &&
        floating &&
        createPortal(
          <div
            ref={(el) => engine.floatAt(el)}
            aria-hidden="true"
            data-slot="carry-float"
            data-refusing={state.refusing === null ? undefined : ""}
            className={cn(
              "pointer-events-none fixed top-0 left-0 z-[100] will-change-transform",
              LIFTED,
              className,
            )}
          >
            {render(state.carried as ID)}
            {/* Over a place that refuses it, the card says so where the eye is: a
                wash of the bad tone over it (Ink's refusal, a state and never the
                card's own colour) and, when the refusal has words, them under it. */}
            {state.refusing !== null && (
              <>
                <span
                  data-slot="carry-refusing"
                  className="absolute inset-0 rounded-[inherit] bg-tone-bad/14 ring-1 ring-tone-bad/50 ring-inset"
                />
                {state.reason && (
                  <span
                    data-slot="carry-reason"
                    className="absolute top-full left-0 mt-2 flex max-w-full items-center gap-1.5 rounded-md border border-border bg-popover px-2 py-1 text-popover-foreground text-xs shadow-md"
                  >
                    <CircleAlert
                      aria-hidden
                      className="size-3.5 shrink-0 text-tone-bad"
                    />
                    {state.reason}
                  </span>
                )}
              </>
            )}
          </div>,
          host,
        )}
    </>
  )
}
carry-core.ts
// CARRY: a thing taken up under a press and put down elsewhere (a card in a list, a
// card across a board's lanes, a column along a strip), the math of it with no DOM
// and no framework: the numbers, one spring, the edge drive, and where the gap
// opens. swift-utils' Ink carries the same way (InkDragList, InkKanban, DragBoard,
// InkEdgeDrive); every number here is Ink's, measured there, said in the unit it is
// felt in. ui/carry binds it to the pointer, the keys and the DOM.
//
// A board is lanes of items (one lane for a list or a strip). An item has an extent
// along the lanes' axis (its height in a column, its width in a strip). While one is
// carried, its slot closes and a gap of its extent opens where it would land: the
// others are moved, never laid out again, so every position follows from the extents
// read once at the lift and the one layout the lanes draw.

export function assert(cond: unknown, msg: string): asserts cond {
  if (!cond) throw new Error(`carry: ${msg}`)
}

/** A spring as SwiftUI states one: how long it takes, and how much it overshoots (0
 *  critically damped, never past its target). */
export type SpringSpec = { duration: number; bounce: number }

/** Ink's carry, number for number (DragMetric, InkRefusal, Press). */
export const CARRY = {
  /** A carried card is the hand's, no longer a container under it: it grows a touch,
   *  from under the press. */
  scale: 1.035,
  /** It leans into its sideways travel by the press's speed: the full lean at
   *  `fullLean` px a second. */
  maxTilt: 3,
  fullLean: 1400,
  /** How far past a neighbour's middle the carried card's middle goes before the gap
   *  crosses it, as a share of that neighbour's extent, either way: a card held on
   *  the line never flickers the gap. */
  hysteresis: 0.2,
  /** Taken up: a little overshoot in the scale, the life of a lift. */
  lift: { duration: 0.3, bounce: 0.3 },
  /** The others gliding out of the way: critically damped, so a gap retargeted
   *  mid-glide carries its velocity on and never wobbles. */
  glide: { duration: 0.3, bounce: 0 },
  /** Let go: landing in the gap, or flying home. 0.1 bounce overshoots under a
   *  thousandth of the distance, so it lands exactly. */
  settle: { duration: 0.32, bounce: 0.1 },
  /** The lean following the travel, and righting itself once the press stops. */
  follow: { duration: 0.18, bounce: 0 },
  upright: { duration: 0.45, bounce: 0.35 },
  /** No movement for this long is a press slowing to a stop: the lean rights. */
  stillMs: 60,
  /** A finger takes a card up once held this long, so a stroke that starts at once
   *  still scrolls (Ink's Press.holdSeconds). */
  holdMs: 280,
  /** A finger that travels this far before the hold is a scroll, never a carry. */
  holdSlop: 10,
  /** A pointer takes a card up once it moves this far: a press that never moves is a
   *  click. */
  travel: 3,
  /** "Not now": a refused step leans toward where it was asked to go, out in 0.09 s
   *  and home on a spring that overshoots once, by what moves: a carried card 14, a
   *  step by keys 10, a count 8. */
  leanCard: 14,
  leanStep: 10,
  leanCount: 8,
} as const satisfies Record<string, number | SpringSpec>

// ── One spring, interruptible ─────────────────────────────────────────────────────

/** A value on a spring: retargeted at any moment, it carries its velocity on, so a
 *  gap moved again mid-glide never jumps. Stiffness and damping are SwiftUI's from a
 *  duration and a bounce (k = (2π/d)², c = 4π(1 - bounce)/d, mass 1), integrated in
 *  small fixed steps. */
export class Spring {
  value: number
  velocity = 0
  target: number
  private k = 0
  private c = 0
  /** Settled once both are under these, in the value's own unit. */
  private readonly rest: number

  constructor(value: number, spec: SpringSpec, rest = 0.01) {
    this.value = value
    this.target = value
    this.rest = rest
    this.tune(spec)
  }

  tune(spec: SpringSpec): void {
    assert(spec.duration > 0, "a spring takes some time")
    assert(spec.bounce >= 0 && spec.bounce < 1, "a bounce is a share below one")
    this.k = ((2 * Math.PI) / spec.duration) ** 2
    this.c = (4 * Math.PI * (1 - spec.bounce)) / spec.duration
  }

  /** Toward `target`, on `spec` when given, its velocity kept. */
  to(target: number, spec?: SpringSpec): void {
    if (spec) this.tune(spec)
    this.target = target
  }

  /** At `value` at once, still. */
  jump(value: number): void {
    this.value = value
    this.target = value
    this.velocity = 0
  }

  get settled(): boolean {
    return (
      Math.abs(this.value - this.target) < this.rest &&
      Math.abs(this.velocity) < this.rest * 10
    )
  }

  /** `dt` seconds on: false once settled (and then exactly at its target). */
  step(dt: number): boolean {
    if (this.settled) {
      this.value = this.target
      this.velocity = 0
      return false
    }
    const h = 1 / 480
    for (let t = 0; t < dt; t += h) {
      const s = Math.min(h, dt - t)
      const a = -this.k * (this.value - this.target) - this.c * this.velocity
      this.velocity += a * s
      this.value += this.velocity * s
    }
    return true
  }
}

// ── The edge drive ────────────────────────────────────────────────────────────────

/** Where a held press starts scrolling a region and how fast (Ink's InkEdgeDrive):
 *  a zone inside each edge, a share of the region bounded in px so a fingertip always
 *  has room; the speed in region lengths a second, rising as the square of the depth
 *  into the zone (the first px creep, where precision lives), full at `reach` zones
 *  deep (half a zone past the edge, where a pointer can go), eased in over `ramp`
 *  seconds once it engages and stopped at once when the press leaves the zone. A press
 *  that lands inside a zone drives that edge only once it travels `arming` px toward
 *  it. Under reduced motion the top speed is a third. */
export const EDGE = {
  zone: 0.15,
  zonePx: { min: 28, max: 80 },
  reach: 1.5,
  top: 1.5,
  ramp: 0.3,
  arming: 8,
} as const

/** The zone's depth for a region `length` px long. */
export function zoneDepth(length: number): number {
  return Math.min(
    Math.max(length * EDGE.zone, EDGE.zonePx.min),
    Math.min(EDGE.zonePx.max, length / 3),
  )
}

/** The share of top speed a press `inside` px from an edge asks (negative past it): 0
 *  outside the zone, the square of the depth, 1 at `reach` zones deep. */
export function edgeShare(inside: number, depth: number): number {
  if (depth <= 0 || inside >= depth) return 0
  const u = Math.min((depth - inside) / depth / EDGE.reach, 1)
  return u * u
}

/** The signed speed, px a second, for a press at `p` in `lo..hi` (negative toward
 *  `lo`), before the ramp. */
export function edgeSpeed(
  p: number,
  lo: number,
  hi: number,
  reduced: boolean,
): number {
  const length = hi - lo
  if (length <= 0) return 0
  const depth = zoneDepth(length)
  const toLow = edgeShare(p - lo, depth)
  const toHigh = edgeShare(hi - p, depth)
  return (toHigh - toLow) * EDGE.top * (reduced ? 1 / 3 : 1) * length
}

/** How much of the speed the ramp lets through `t` seconds after engaging: a
 *  smoothstep, never a jump. */
export function ramped(t: number): number {
  const x = Math.min(Math.max(t / EDGE.ramp, 0), 1)
  return x * x * (3 - 2 * x)
}

/** A region along one axis: where it starts and ends, px. */
export type Span = { lo: number; hi: number }

/** Runs the edge drive while a press is held: told where the press is over a region
 *  (`follow`), it steps the region once a frame while the press is in a zone, then
 *  sleeps. `step` moves the region by the px it is given and says whether it could;
 *  at the region's end the drive waits for the press to move again. */
export class EdgeDriver {
  reduced = false
  private point: number | null = null
  private span: Span = { lo: 0, hi: 0 }
  private origin: number | null = null
  private armed = { low: false, high: false }
  private engaged: number | null = null
  private last: number | null = null
  private frame = 0

  constructor(private readonly step: (px: number) => boolean) {}

  get driving(): boolean {
    return this.engaged !== null
  }

  /** The press is at `p` over `span`; the first call of a press is where it began. */
  follow(p: number, span: Span): void {
    this.span = span
    this.point = p
    if (this.origin === null) {
      this.origin = p
      const depth = zoneDepth(span.hi - span.lo)
      this.armed = { low: p - span.lo >= depth, high: span.hi - p >= depth }
    }
    if (p <= this.origin - EDGE.arming) this.armed.low = true
    if (p >= this.origin + EDGE.arming) this.armed.high = true
    if (this.velocity() === 0) {
      this.pause()
      return
    }
    if (this.frame !== 0) return
    this.engaged ??= performance.now()
    this.last = null
    this.frame = requestAnimationFrame(this.tick)
  }

  /** The press lifted or was taken away: the region stops at once. */
  end(): void {
    this.pause()
    this.point = null
    this.origin = null
    this.armed = { low: false, high: false }
  }

  private velocity(): number {
    if (this.point === null) return 0
    const s = edgeSpeed(this.point, this.span.lo, this.span.hi, this.reduced)
    return s < 0 ? (this.armed.low ? s : 0) : this.armed.high ? s : 0
  }

  private pause(): void {
    if (this.frame !== 0) cancelAnimationFrame(this.frame)
    this.frame = 0
    this.engaged = null
    this.last = null
  }

  private tick = (now: number): void => {
    this.frame = 0
    const v = this.velocity()
    if (v === 0 || this.engaged === null) {
      this.pause()
      return
    }
    const dt = Math.min((now - (this.last ?? now - 1000 / 120)) / 1000, 1 / 30)
    this.last = now
    const px = v * ramped((now - this.engaged) / 1000) * dt
    // At the region's end: nothing to drive until the press moves again.
    if (!this.step(px)) return
    this.frame = requestAnimationFrame(this.tick)
  }
}

// ── Where the gap opens ───────────────────────────────────────────────────────────

/** A place in a board: a lane, and an index in it counted without the carried item,
 *  which is also its index once it lands there. */
export type Slot = { lane: number; index: number }

export const sameSlot = (a: Slot, b: Slot) =>
  a.lane === b.lane && a.index === b.index

/** A lane as read at the lift: its items in order, each one's extent, the padding
 *  before the first and the spacing between them (the lane's own CSS gap). */
export type LaneLayout<ID> = {
  ids: readonly ID[]
  extent: ReadonlyMap<ID, number>
  pad: number
  spacing: number
}

/** A carry in progress, as the layout reads it. One item is carried (the pressed
 *  one), with the items carried together with it (`group`, itself included: a
 *  selection of rows), in one lane. */
export type CarryState<ID> = {
  carried: ID
  group: ReadonlySet<ID>
  /** Where the group was, counted among the items that stay (the first of the
   *  group's place). */
  source: Slot
  target: Slot
  /** The group's extent together (and the spacing between its items): the gap it
   *  leaves and opens. */
  gap: number
  /** The lane under the card that refuses it: no gap opens anywhere. */
  refusing: number | null
  /** Let go and travelling into its slot: its gap is already the layout. */
  settling: boolean
  /** Going home: every item back in its own place, the group's included. */
  home: boolean
}

/** A lane's items without the carried ones. */
export function others<ID>(lane: LaneLayout<ID>, group: ReadonlySet<ID>): ID[] {
  return lane.ids.filter((id) => !group.has(id))
}

/** The gap a group leaves in its lane: its items' extents and the spacing between. */
export function gapOf<ID>(lane: LaneLayout<ID>, group: readonly ID[]): number {
  let gap = 0
  for (const id of group) gap += lane.extent.get(id) ?? 0
  return gap + lane.spacing * Math.max(0, group.length - 1)
}

/** Where item `index` of `ids` begins along its lane. */
export function startOf<ID>(
  lane: LaneLayout<ID>,
  ids: readonly ID[],
  index: number,
): number {
  let at = lane.pad
  for (let i = 0; i < index; i++)
    at += (lane.extent.get(ids[i] as ID) ?? 0) + lane.spacing
  return at
}

/** Where a slot's gap begins along its lane. */
export function slotStart<ID>(
  lanes: readonly LaneLayout<ID>[],
  group: ReadonlySet<ID>,
  slot: Slot,
): number {
  const lane = lanes[slot.lane]
  assert(lane, `no lane ${slot.lane}`)
  return startOf(lane, others(lane, group), slot.index)
}

/** How far each item of `lane` (in the lane's own order) moves out of the carried
 *  group's way: back by the slots of the group before it, which close, and on by the
 *  gap where it opens. Over a lane that refuses it no gap opens, not even at home,
 *  until it is let go; going home, nothing is moved. */
export function shiftsOf<ID>(
  lanes: readonly LaneLayout<ID>[],
  s: CarryState<ID>,
  lane: number,
): number[] {
  const l = lanes[lane]
  if (!l) return []
  if (s.home) return l.ids.map(() => 0)
  const opens = lane === s.target.lane && (s.refusing === null || s.settling)
  // One pass: the group's slots closed so far, and the place among those that stay.
  let closed = 0
  let at = 0
  return l.ids.map((id) => {
    if (s.group.has(id)) {
      if (lane === s.source.lane) closed += (l.extent.get(id) ?? 0) + l.spacing
      return 0
    }
    const by = -closed + (opens && at >= s.target.index ? s.gap + l.spacing : 0)
    at += 1
    return by
  })
}

/** Room a lane gains at its foot while a card from another lane would land in it. */
export function extraOf<ID>(
  lanes: readonly LaneLayout<ID>[],
  s: CarryState<ID>,
  lane: number,
): number {
  return !s.home && lane === s.target.lane && lane !== s.source.lane
    ? s.gap + (lanes[lane]?.spacing ?? 0)
    : 0
}

/** How far what follows a lane's last item moves back once the carried card's slot
 *  has closed (its gap open in another lane, or none anywhere over a refusing lane). */
export function footShiftOf<ID>(
  lanes: readonly LaneLayout<ID>[],
  s: CarryState<ID>,
  lane: number,
): number {
  if (s.home || lane !== s.source.lane) return 0
  const closed =
    s.target.lane !== s.source.lane || (s.refusing !== null && !s.settling)
  return closed ? -(s.gap + (lanes[lane]?.spacing ?? 0)) : 0
}

/** Where the carried card's middle is (`middle`, along `lane`), the gap opens: the
 *  index it would take there, crossing a neighbour only once the middle is
 *  `hysteresis` of that neighbour past its middle, either way from where the gap is. */
export function placeAt<ID>(
  lanes: readonly LaneLayout<ID>[],
  s: CarryState<ID>,
  lane: number,
  middle: number,
): Slot {
  const l = lanes[lane]
  assert(l, `no lane ${lane}`)
  const ids = others(l, s.group)
  const open =
    s.refusing === null && s.target.lane === lane ? s.target.index : null
  let index = 0
  while (index < ids.length) {
    const h = l.extent.get(ids[index] as ID) ?? 0
    let line = startOf(l, ids, index) + h / 2
    if (open !== null) line += (index >= open ? 1 : -1) * h * CARRY.hysteresis
    if (middle <= line) break
    index += 1
  }
  return { lane, index }
}

/** A step by keys from `target`: a place along its lane, or the next lane at the same
 *  index (clamped to what that lane holds). */
export function stepFrom<ID>(
  lanes: readonly LaneLayout<ID>[],
  s: CarryState<ID>,
  dLane: number,
  dIndex: number,
): Slot {
  if (dLane !== 0) {
    const lane = Math.min(Math.max(s.target.lane + dLane, 0), lanes.length - 1)
    const l = lanes[lane] as LaneLayout<ID>
    return {
      lane,
      index: Math.min(s.target.index, others(l, s.group).length),
    }
  }
  const l = lanes[s.target.lane] as LaneLayout<ID>
  return {
    lane: s.target.lane,
    index: Math.min(
      Math.max(s.target.index + dIndex, 0),
      others(l, s.group).length,
    ),
  }
}

/** The lean for a sideways speed, degrees. */
export function tiltFor(speed: number): number {
  return Math.max(-1, Math.min(1, speed / CARRY.fullLean)) * CARRY.maxTilt
}