{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"data-table","type":"registry:component","title":"Data table","description":"A table whose rows travel: sort and every row glides to its rank, live data re-ranks itself, and selection turns the toolbar into its actions.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json","https://hairlineui.com/r/status-button.json"],"files":[{"path":"registry/data-table.tsx","type":"registry:component","content":"// Hairline UI · data-table · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUp, ChevronDown, ChevronLeft, ChevronRight, Search, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { useRowMotion } from \"./use-row-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * DATA TABLE: rows that travel\n *\n *   sort      click a header and every row glides to its new\n *             rank, top first; rows that didn't move stay put;\n *             the arrow flips with a slight overshoot\n *   live      new data re-sorts itself: a row whose amount\n *             grows glides past the ones above it, new rows\n *             fade in as the rows below make room\n *   search    rows that stop matching fold away, the rest close\n *             up, and \"248 payments\" rolls down to the count\n *   select    tick a row and the toolbar becomes the selection\n *             (\"3 selected · Export · Delete\"); Shift ticks a\n *             range one row after another\n *   remove    rows fold out and the toolbar holds \"3 deleted ·\n *             Undo\" over a draining hairline before it commits\n *   detail    a row opens into its detail by measured height\n *   edit      double-click a cell (or Enter on it) to edit in\n *             place; it saves with a check, or shakes back\n *   pages     the next page slides in from the side you went;\n *             \"11–20 of 248\" rolls\n *\n * Narrow containers get one card per row instead of columns.\n * ───────────────────────────────────────────────────────── */\n\nexport type DataTableValue = string | number | Date | null | undefined;\n\nexport interface DataTableColumn<T> {\n  key: string;\n  header: string;\n  /** What the column sorts, searches and edits by. Defaults to row[key]. */\n  value?: (row: T) => DataTableValue;\n  /** What the cell shows. Defaults to the value as text. */\n  cell?: (row: T) => React.ReactNode;\n  align?: \"left\" | \"right\";\n  /** A grid track, e.g. \"minmax(0,1.6fr)\" or \"120px\". */\n  width?: string;\n  sortable?: boolean;\n  /** Search looks in this column. */\n  searchable?: boolean;\n  /** Double-click or Enter edits it in place (needs onEdit). */\n  editable?: boolean;\n  /** Its place in the card a narrow container shows instead of columns. */\n  card?: \"title\" | \"subtitle\" | \"trailing\" | \"hidden\";\n}\n\nexport interface DataTableAction<T> {\n  id: string;\n  label: string;\n  /** Labels while it runs and once it's done, e.g. [\"Exporting\", \"Exported\"]. */\n  progress?: [string, string];\n  tone?: \"default\" | \"destructive\";\n  /** The rows leave at once and the toolbar offers Undo; run() is called when the chance to undo is over. */\n  removes?: boolean;\n  run: (rows: T[]) => Promise<void> | void;\n}\n\nexport type DataTableSort = { key: string; dir: \"asc\" | \"desc\" };\n\nexport interface DataTableProps<T> extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  columns: DataTableColumn<T>[];\n  rows: T[];\n  /** Defaults to row.id. */\n  getRowId?: (row: T) => string;\n  /** How a row is named when announced; defaults to its first column. */\n  rowLabel?: (row: T) => string;\n  /** What a row is called: [\"payment\", \"payments\"]. */\n  noun?: [string, string];\n  sort?: DataTableSort | null;\n  defaultSort?: DataTableSort | null;\n  onSortChange?: (sort: DataTableSort | null) => void;\n  /** Shows the search field; it looks in columns marked searchable (or all, if none are). */\n  searchable?: boolean;\n  searchPlaceholder?: string;\n  selectable?: boolean;\n  selected?: string[];\n  defaultSelected?: string[];\n  onSelectedChange?: (ids: string[]) => void;\n  /** What the toolbar offers for the selected rows. */\n  actions?: DataTableAction<T>[];\n  /** How long Undo is offered after a removing action, ms. */\n  undoFor?: number;\n  /** Saves an edited cell. Throw to put the old value back. */\n  onEdit?: (row: T, key: string, value: string) => Promise<void> | void;\n  /** Rows open into this. */\n  renderDetail?: (row: T) => React.ReactNode;\n  pageSize?: number;\n  label?: string;\n}\n\ntype Undo<T> = { key: number; ids: string[]; rows: T[]; action: DataTableAction<T> };\ntype Cursor = { id: string; col: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst CELL_FOCUS = \"outline-none focus-visible:shadow-[inset_0_0_0_1.5px_var(--ring)]\";\nconst NO_IDS: string[] = [];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst swap = (on: boolean, reduced: boolean, from = 8): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : `translateY(${from}px)`,\n  filter: on ? \"none\" : \"blur(4px)\",\n  transition: reduced ? \"none\" : `opacity 240ms ${EASE}, transform 420ms ${EASE}, filter 240ms ${EASE}`,\n  pointerEvents: on ? undefined : \"none\",\n});\n\nconst compare = (a: DataTableValue, b: DataTableValue) => {\n  if (a == null && b == null) return 0;\n  if (a == null) return 1;\n  if (b == null) return -1;\n  if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();\n  if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n  return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: \"base\" });\n};\n\n/** A checkbox that draws: the check (or the dash, for some) strokes itself in. */\nfunction Box({ state, reduced, delay = 0 }: { state: \"on\" | \"off\" | \"some\"; reduced: boolean; delay?: number }) {\n  const t = (on: boolean) => (reduced ? \"none\" : `stroke-dashoffset ${on ? 300 : 120}ms ${EASE} ${on ? delay + 60 : 0}ms`);\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"relative flex size-4 items-center justify-center rounded-[5px] transition-[background-color,box-shadow] duration-200\"\n      style={{\n        background: state === \"off\" ? \"var(--background)\" : \"var(--primary)\",\n        boxShadow: state === \"off\" ? \"inset 0 0 0 1px color-mix(in oklab, var(--foreground) 28%, transparent)\" : \"none\",\n        transitionDelay: state === \"off\" ? \"0ms\" : `${delay}ms`,\n      }}\n    >\n      <svg viewBox=\"0 0 16 16\" className=\"absolute size-3 text-primary-foreground\" fill=\"none\">\n        <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: state === \"on\" ? 0 : 1, transition: t(state === \"on\") }} />\n        <path d=\"M4 8h8\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: state === \"some\" ? 0 : 1, transition: t(state === \"some\") }} />\n      </svg>\n    </span>\n  );\n}\n\nexport function DataTable<T>({\n  columns,\n  rows,\n  getRowId = (row) => String((row as { id?: unknown }).id),\n  rowLabel,\n  noun = [\"row\", \"rows\"],\n  sort: sortProp,\n  defaultSort = null,\n  onSortChange,\n  searchable = true,\n  searchPlaceholder = \"Search\",\n  selectable = false,\n  selected: selectedProp,\n  defaultSelected = NO_IDS,\n  onSelectedChange,\n  actions = [],\n  undoFor = 5000,\n  onEdit,\n  renderDetail,\n  pageSize = 10,\n  label = \"Table\",\n  className = \"\",\n  ...props\n}: DataTableProps<T>) {\n  const reduced = useReducedMotion();\n  const uid = React.useId();\n  const [sortOwn, setSortOwn] = React.useState<DataTableSort | null>(defaultSort);\n  const sort = sortProp === undefined ? sortOwn : sortProp;\n  const [selectedOwn, setSelectedOwn] = React.useState<string[]>(defaultSelected);\n  const selectedIds = selectedProp ?? selectedOwn;\n  const selected = React.useMemo(() => new Set(selectedIds), [selectedIds]);\n  const [query, setQuery] = React.useState(\"\");\n  const [page, setPage] = React.useState(0);\n  const [pageDir, setPageDir] = React.useState(0);\n  const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [undo, setUndo] = React.useState<Undo<T> | null>(null);\n  const [open, setOpen] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [editing, setEditingState] = React.useState<{ id: string; key: string } | null>(null);\n  // Enter and the blur that follows it both finish an edit; only the first counts.\n  const editingRef = React.useRef<{ id: string; key: string } | null>(null);\n  const setEditing = (e: { id: string; key: string } | null) => {\n    editingRef.current = e;\n    setEditingState(e);\n  };\n  const [drafts, setDrafts] = React.useState<ReadonlyMap<string, { value: string; status: \"pending\" | \"done\" }>>(() => new Map());\n  const [actionStatus, setActionStatus] = React.useState<Record<string, ActionStatus>>({});\n  const [notice, setNotice] = React.useState<{ text: string; key: number } | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const [cursor, setCursor] = React.useState<Cursor | null>(null);\n  const [cascade, setCascade] = React.useState<{ ids: string[]; key: number } | null>(null);\n  const [holdUndo, setHoldUndo] = React.useState(false);\n  const anchor = React.useRef<string | null>(null);\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const gridRef = React.useRef<HTMLDivElement>(null);\n  const lastSeen = React.useRef(new Map<string, T>());\n\n  const name = (row: T) => rowLabel?.(row) ?? String(valueOf(columns[0], row) ?? getRowId(row));\n  const valueOf = (c: DataTableColumn<T>, row: T): DataTableValue => (c.value ? c.value(row) : ((row as Record<string, unknown>)[c.key] as DataTableValue));\n  const count = (n: number) => `${n === 1 ? noun[0] : noun[1]}`;\n  const say = (text: string) => setAnnounce((a) => (a === text ? `${text} ` : text));\n  const flash = (text: string) => {\n    const key = Date.now();\n    setNotice({ text, key });\n    window.setTimeout(() => setNotice((n) => (n?.key === key ? null : n)), 3600);\n  };\n\n  const setSelected = (next: string[]) => {\n    if (selectedProp === undefined) setSelectedOwn(next);\n    onSelectedChange?.(next);\n  };\n\n  // ── Rows in view ───────────────────────────────────────\n  const searchCols = columns.some((c) => c.searchable) ? columns.filter((c) => c.searchable) : columns;\n  const q = query.trim().toLowerCase();\n  const visible = rows.filter((r) => !hidden.has(getRowId(r)));\n  const matched = q ? visible.filter((r) => searchCols.some((c) => String(valueOf(c, r) ?? \"\").toLowerCase().includes(q))) : visible;\n  const sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined;\n  const sorted = sortCol ? [...matched].sort((a, b) => compare(valueOf(sortCol, a), valueOf(sortCol, b)) * (sort!.dir === \"asc\" ? 1 : -1)) : matched;\n  const pages = Math.max(1, Math.ceil(sorted.length / pageSize));\n  const current = Math.min(page, pages - 1);\n  const pageRows = sorted.slice(current * pageSize, current * pageSize + pageSize);\n  for (const r of pageRows) lastSeen.current.set(getRowId(r), r);\n\n  const ranks = React.useMemo(() => new Map(sorted.map((r, i) => [getRowId(r), i])), [sorted, getRowId]);\n  const motion = useRowMotion(bodyRef, { reduced, resetKey: current, ranks });\n\n  // A new page slides in from the side you went toward.\n  const shownPage = React.useRef(current);\n  React.useLayoutEffect(() => {\n    if (shownPage.current === current) return;\n    const dir = Math.sign(current - shownPage.current) || pageDir;\n    shownPage.current = current;\n    if (reduced) return;\n    bodyRef.current?.animate(\n      [\n        { transform: `translateX(${dir * 28}px)`, opacity: 0, filter: \"blur(4px)\" },\n        { transform: \"none\", opacity: 1, filter: \"blur(0px)\" },\n      ],\n      { duration: 440, easing: EASE },\n    );\n  }, [current, pageDir, reduced]);\n\n  // The list's height eases to fit, so rows folding away or arriving never make the page jump.\n  React.useLayoutEffect(() => {\n    const wrap = wrapRef.current;\n    const body = bodyRef.current;\n    if (!wrap || !body) return;\n    const fit = () => {\n      const h = `${body.offsetHeight}px`;\n      if (wrap.style.height === h) return;\n      wrap.style.transition = wrap.dataset.ready && !reduced ? `height 420ms ${EASE}` : \"none\";\n      wrap.style.height = h;\n      wrap.dataset.ready = \"1\";\n    };\n    fit();\n    const observer = new ResizeObserver(fit);\n    observer.observe(body);\n    return () => observer.disconnect();\n  }, [reduced]);\n\n  // ── Sorting ────────────────────────────────────────────\n  const sortBy = (key: string) => {\n    const col = columns.find((c) => c.key === key)!;\n    const sample = rows.map((r) => valueOf(col, r)).find((v) => v != null);\n    const firstDir = typeof sample === \"number\" || sample instanceof Date ? \"desc\" : \"asc\";\n    const next: DataTableSort | null = sort?.key !== key ? { key, dir: firstDir } : { key, dir: sort.dir === \"asc\" ? \"desc\" : \"asc\" };\n    motion.stagger();\n    if (sortProp === undefined) setSortOwn(next);\n    onSortChange?.(next);\n    say(`Sorted by ${col.header}, ${next.dir === \"asc\" ? \"ascending\" : \"descending\"}.`);\n  };\n\n  // ── Selection ──────────────────────────────────────────\n  const toggle = (id: string, range: boolean) => {\n    const ids = sorted.map(getRowId);\n    if (range && anchor.current && ids.includes(anchor.current)) {\n      const a = ids.indexOf(anchor.current);\n      const b = ids.indexOf(id);\n      const span = ids.slice(Math.min(a, b), Math.max(a, b) + 1);\n      const on = !selected.has(id);\n      const next = new Set(selected);\n      span.forEach((x) => (on ? next.add(x) : next.delete(x)));\n      // Ticks run down the range one after another, from the anchor.\n      setCascade({ ids: a <= b ? span : [...span].reverse(), key: Date.now() });\n      setSelected([...next]);\n      say(`${next.size} selected.`);\n    } else {\n      const next = new Set(selected);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      setCascade(null);\n      setSelected([...next]);\n      say(`${next.size ? `${next.size} selected` : \"Selection cleared\"}.`);\n    }\n    anchor.current = id;\n  };\n  const allIds = sorted.map(getRowId);\n  const allState: \"on\" | \"off\" | \"some\" = allIds.length && allIds.every((i) => selected.has(i)) ? \"on\" : allIds.some((i) => selected.has(i)) ? \"some\" : \"off\";\n  const toggleAll = () => {\n    setCascade(allState === \"on\" ? null : { ids: pageRows.map(getRowId), key: Date.now() });\n    setSelected(allState === \"on\" ? [] : allIds);\n    say(allState === \"on\" ? \"Selection cleared.\" : `${allIds.length} selected.`);\n  };\n\n  // ── Actions ────────────────────────────────────────────\n  const run = async (action: DataTableAction<T>) => {\n    const chosen = rows.filter((r) => selected.has(getRowId(r)));\n    if (!chosen.length) return;\n    if (action.removes) {\n      const ids = chosen.map(getRowId);\n      // Commit whatever was waiting before this one takes the toolbar.\n      if (undo) commitUndo(undo);\n      setHidden((h) => new Set([...h, ...ids]));\n      setSelected([]);\n      setUndo({ key: Date.now(), ids, rows: chosen, action });\n      say(`${ids.length} ${count(ids.length)} ${action.progress?.[1]?.toLowerCase() ?? \"removed\"}. Undo is available.`);\n      return;\n    }\n    setActionStatus((s) => ({ ...s, [action.id]: \"pending\" }));\n    try {\n      await action.run(chosen);\n      setActionStatus((s) => ({ ...s, [action.id]: \"success\" }));\n      say(`${action.progress?.[1] ?? \"Done\"}: ${chosen.length} ${count(chosen.length)}.`);\n    } catch {\n      setActionStatus((s) => ({ ...s, [action.id]: \"error\" }));\n      say(`${action.label} failed.`);\n    }\n  };\n\n  const commitUndo = async (u: Undo<T>) => {\n    setUndo((cur) => (cur?.key === u.key ? null : cur));\n    try {\n      await u.action.run(u.rows);\n    } catch {\n      setHidden((h) => {\n        const n = new Set(h);\n        u.ids.forEach((i) => n.delete(i));\n        return n;\n      });\n      flash(`Couldn't ${u.action.label.toLowerCase()} ${u.ids.length} ${count(u.ids.length)}. They're back.`);\n      say(`${u.action.label} failed. The rows are back.`);\n    }\n  };\n\n  const takeBack = () => {\n    if (!undo) return;\n    setHidden((h) => {\n      const n = new Set(h);\n      undo.ids.forEach((i) => n.delete(i));\n      return n;\n    });\n    setUndo(null);\n    say(`Undone. ${undo.ids.length} ${count(undo.ids.length)} back.`);\n  };\n\n  // ── Editing ────────────────────────────────────────────\n  const finishEdit = async (row: T, key: string, value: string | null) => {\n    const id = getRowId(row);\n    if (editingRef.current?.id !== id || editingRef.current.key !== key) return;\n    setEditing(null);\n    const cellKey = `${id}:${key}`;\n    requestAnimationFrame(() => gridRef.current?.querySelector<HTMLElement>(`[data-row-id=\"${CSS.escape(id)}\"] [data-col-key=\"${CSS.escape(key)}\"]`)?.focus({ preventScroll: true }));\n    const col = columns.find((c) => c.key === key)!;\n    if (value === null || value === String(valueOf(col, row) ?? \"\") || !onEdit) return;\n    setDrafts((d) => new Map(d).set(cellKey, { value, status: \"pending\" }));\n    try {\n      await onEdit(row, key, value);\n      setDrafts((d) => new Map(d).set(cellKey, { value, status: \"done\" }));\n      say(`Saved ${col.header.toLowerCase()}.`);\n      window.setTimeout(() => setDrafts((d) => {\n        const n = new Map(d);\n        if (n.get(cellKey)?.status === \"done\") n.delete(cellKey);\n        return n;\n      }), 1400);\n    } catch {\n      setDrafts((d) => {\n        const n = new Map(d);\n        n.delete(cellKey);\n        return n;\n      });\n      if (!reduced)\n        gridRef.current?.querySelector<HTMLElement>(`[data-row-id=\"${CSS.escape(id)}\"] [data-col-key=\"${CSS.escape(key)}\"]`)?.animate(\n          [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n          { duration: 380, easing: \"ease-out\" },\n        );\n      flash(`Couldn't save ${col.header.toLowerCase()} for ${name(row)}.`);\n      say(`Couldn't save. ${col.header} is back to what it was.`);\n    }\n  };\n\n  // ── Pages ──────────────────────────────────────────────\n  const goPage = (dir: number) => {\n    const next = Math.max(0, Math.min(pages - 1, current + dir));\n    if (next === current) return;\n    setPageDir(dir);\n    setPage(next);\n    setCursor(null);\n    say(`Page ${next + 1} of ${pages}.`);\n  };\n\n  // ── Keys: rows, then cells ─────────────────────────────\n  const editable = (c: DataTableColumn<T>) => !!(c.editable && onEdit);\n  const focusAt = (c: Cursor) => {\n    setCursor(c);\n    requestAnimationFrame(() => {\n      const row = gridRef.current?.querySelector<HTMLElement>(`[data-row-id=\"${CSS.escape(c.id)}\"]`);\n      const cell = c.col < 0 ? null : row?.querySelector<HTMLElement>(`[data-col-index=\"${c.col}\"]`);\n      // Narrow, the cells are a card and can't take focus: the row does.\n      (cell && cell.offsetParent ? cell : row)?.focus({ preventScroll: false });\n    });\n  };\n  const onGridKey = (e: React.KeyboardEvent) => {\n    if (editing || (e.target as HTMLElement).closest(\"input, button\")) return;\n    const ids = pageRows.map(getRowId);\n    const c = cursor && ids.includes(cursor.id) ? cursor : ids.length ? { id: ids[0], col: -1 } : null;\n    if (!c) return;\n    const i = ids.indexOf(c.id);\n    const row = pageRows[i];\n    const keys: Record<string, () => void> = {\n      ArrowDown: () => i < ids.length - 1 && focusAt({ id: ids[i + 1], col: c.col }),\n      ArrowUp: () => i > 0 && focusAt({ id: ids[i - 1], col: c.col }),\n      ArrowRight: () => c.col < columns.length - 1 && focusAt({ id: c.id, col: c.col + 1 }),\n      ArrowLeft: () => c.col > -1 && focusAt({ id: c.id, col: c.col - 1 }),\n      Home: () => focusAt({ id: ids[0], col: c.col }),\n      End: () => focusAt({ id: ids[ids.length - 1], col: c.col }),\n      PageDown: () => goPage(1),\n      PageUp: () => goPage(-1),\n      \" \": () => selectable && toggle(c.id, e.shiftKey),\n      Enter: () => {\n        const col = columns[c.col];\n        if (col && editable(col)) setEditing({ id: c.id, key: col.key });\n        else if (renderDetail) toggleOpen(c.id, row);\n      },\n    };\n    const go = keys[e.key];\n    if (!go) return;\n    e.preventDefault();\n    go();\n  };\n\n  const toggleOpen = (id: string, row: T) => {\n    setOpen((o) => {\n      const n = new Set(o);\n      if (n.has(id)) n.delete(id);\n      else n.add(id);\n      return n;\n    });\n    say(`${open.has(id) ? \"Closed\" : \"Opened\"} ${name(row)}.`);\n  };\n\n  // ── Render ─────────────────────────────────────────────\n  const tracks = `${selectable ? \"40px \" : \"\"}${columns.map((c) => c.width ?? \"minmax(0,1fr)\").join(\" \")}${renderDetail ? \" 40px\" : \"\"}`;\n  const mode = undo ? \"undo\" : selected.size ? \"selection\" : \"search\";\n  const from = sorted.length ? current * pageSize + 1 : 0;\n  const to = Math.min(sorted.length, current * pageSize + pageSize);\n  const title = columns.find((c) => c.card === \"title\") ?? columns[0];\n  const subtitles = columns.filter((c) => c.card === \"subtitle\");\n  const trailing = columns.filter((c) => c.card === \"trailing\");\n  const sortable = columns.filter((c) => c.sortable);\n\n  const renderCell = (row: T, col: DataTableColumn<T>) => {\n    const id = getRowId(row);\n    const draft = drafts.get(`${id}:${col.key}`);\n    if (editing?.id === id && editing.key === col.key)\n      return (\n        <input\n          autoFocus\n          defaultValue={String(valueOf(col, row) ?? \"\")}\n          aria-label={`${col.header} for ${name(row)}`}\n          onFocus={(e) => e.currentTarget.select()}\n          onKeyDown={(e) => {\n            e.stopPropagation();\n            if (e.key === \"Enter\") finishEdit(row, col.key, e.currentTarget.value);\n            if (e.key === \"Escape\") finishEdit(row, col.key, null);\n          }}\n          onBlur={(e) => finishEdit(row, col.key, e.currentTarget.value)}\n          className={`-mx-1.5 h-8 w-[calc(100%+12px)] min-w-0 rounded-[8px] bg-background px-1.5 text-[13px] shadow-[inset_0_0_0_1.5px_var(--ring)] outline-none [@media(pointer:coarse)]:text-[16px] ${col.align === \"right\" ? \"text-right\" : \"\"}`}\n        />\n      );\n    return (\n      <span className={`flex min-w-0 items-center gap-1.5 ${col.align === \"right\" ? \"justify-end\" : \"\"}`}>\n        <span className=\"min-w-0 truncate\">{draft ? <TextMorph animateWidth={false}>{draft.value}</TextMorph> : col.cell ? col.cell(row) : String(valueOf(col, row) ?? \"\")}</span>\n        {draft && (\n          <span className=\"relative size-3.5 shrink-0\" aria-hidden=\"true\">\n            <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(draft.status === \"pending\", reduced, 0)}>\n              <span className=\"size-3 rounded-full border-[1.5px] border-muted-foreground border-t-transparent motion-safe:animate-spin\" />\n            </span>\n            <svg viewBox=\"0 0 16 16\" className=\"absolute inset-0 size-3.5 text-emerald-600 dark:text-emerald-400\" fill=\"none\" style={{ opacity: draft.status === \"done\" ? 1 : 0, transition: `opacity 200ms ${EASE}` }}>\n              <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: draft.status === \"done\" ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 380ms ${EASE} 80ms` }} />\n            </svg>\n          </span>\n        )}\n      </span>\n    );\n  };\n\n  const renderRow = (row: T, ghost = false) => {\n    const id = getRowId(row);\n    const isOpen = open.has(id);\n    const isSelected = selected.has(id);\n    const delay = cascade && !ghost ? Math.max(0, cascade.ids.indexOf(id)) * 28 : 0;\n    const rowFocused = cursor?.id === id;\n    return (\n      <>\n        {/* Columns, wide */}\n        <div className=\"grid min-h-12 items-center @max-[34rem]:hidden\" style={{ gridTemplateColumns: tracks }}>\n          {selectable && (\n            <span role=\"gridcell\" className=\"flex items-center justify-center\">\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                aria-label={`Select ${name(row)}`}\n                aria-pressed={isSelected}\n                onClick={(e) => toggle(id, e.shiftKey)}\n                className=\"flex size-8 cursor-pointer items-center justify-center rounded-[8px] hover:bg-accent\"\n              >\n                <Box state={isSelected ? \"on\" : \"off\"} reduced={reduced} delay={delay} />\n              </button>\n            </span>\n          )}\n          {columns.map((col, ci) => (\n            <span\n              key={col.key}\n              role=\"gridcell\"\n              data-col-key={col.key}\n              data-col-index={ci}\n              tabIndex={!ghost && rowFocused && cursor?.col === ci ? 0 : -1}\n              onFocus={() => !ghost && setCursor({ id, col: ci })}\n              onDoubleClick={() => !ghost && editable(col) && setEditing({ id, key: col.key })}\n              className={`min-w-0 rounded-[6px] px-3 py-2 text-[13px] ${col.align === \"right\" ? \"text-right tabular-nums\" : \"\"} ${editable(col) ? \"cursor-text\" : \"\"} ${CELL_FOCUS}`}\n            >\n              {ghost ? (col.cell ? col.cell(row) : String(valueOf(col, row) ?? \"\")) : renderCell(row, col)}\n            </span>\n          ))}\n          {renderDetail && (\n            <span role=\"gridcell\" className=\"flex items-center justify-center\">\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                aria-label={`${isOpen ? \"Close\" : \"Open\"} ${name(row)}`}\n                aria-expanded={isOpen}\n                onClick={() => toggleOpen(id, row)}\n                className=\"flex size-8 cursor-pointer items-center justify-center rounded-[8px] text-muted-foreground hover:bg-accent hover:text-foreground\"\n              >\n                <ChevronDown className=\"size-4\" strokeWidth={1.75} style={{ transform: isOpen ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 420ms ${THROW}` }} />\n              </button>\n            </span>\n          )}\n        </div>\n\n        {/* One card, narrow */}\n        <div className=\"hidden min-h-[60px] items-center gap-3 py-2.5 pr-3 @max-[34rem]:flex\" style={{ paddingLeft: selectable ? 0 : 12 }}>\n          {selectable && (\n            <button type=\"button\" tabIndex={-1} aria-label={`Select ${name(row)}`} aria-pressed={isSelected} onClick={(e) => toggle(id, e.shiftKey)} className=\"flex size-10 shrink-0 cursor-pointer items-center justify-center\">\n              <Box state={isSelected ? \"on\" : \"off\"} reduced={reduced} delay={delay} />\n            </button>\n          )}\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            disabled={!renderDetail}\n            onClick={() => renderDetail && toggleOpen(id, row)}\n            className=\"flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left disabled:cursor-default\"\n          >\n            <span className=\"min-w-0 flex-1\">\n              <span className=\"block truncate text-[13.5px] font-medium\">{title.cell ? title.cell(row) : String(valueOf(title, row) ?? \"\")}</span>\n              {subtitles.length > 0 && (\n                <span className=\"mt-0.5 flex min-w-0 items-center gap-1.5 truncate text-[12px] text-muted-foreground\">\n                  {subtitles.map((c, i) => (\n                    <React.Fragment key={c.key}>\n                      {i > 0 && <span aria-hidden=\"true\">·</span>}\n                      <span className=\"truncate\">{c.cell ? c.cell(row) : String(valueOf(c, row) ?? \"\")}</span>\n                    </React.Fragment>\n                  ))}\n                </span>\n              )}\n            </span>\n            {trailing.length > 0 && (\n              <span className=\"flex shrink-0 flex-col items-end gap-1 text-[13px] tabular-nums\">\n                {trailing.map((c) => (\n                  <span key={c.key}>{c.cell ? c.cell(row) : String(valueOf(c, row) ?? \"\")}</span>\n                ))}\n              </span>\n            )}\n          </button>\n        </div>\n\n        {/* Detail: the row opens by its own measured height. */}\n        {renderDetail && (\n          <div className=\"grid transition-[grid-template-rows] duration-[420ms]\" style={{ gridTemplateRows: isOpen ? \"1fr\" : \"0fr\", transitionTimingFunction: EASE }} inert={!isOpen}>\n            <div className=\"min-h-0 overflow-hidden\">\n              <div className=\"border-t border-dashed border-border px-3 pb-4 pt-3 @max-[34rem]:px-3\" style={{ paddingLeft: selectable ? 52 : 12, opacity: isOpen ? 1 : 0, transition: reduced ? \"none\" : `opacity 300ms ${EASE} ${isOpen ? 120 : 0}ms` }}>\n                {renderDetail(row)}\n              </div>\n            </div>\n          </div>\n        )}\n      </>\n    );\n  };\n\n  return (\n    <div className={`@container w-full text-foreground ${className}`} {...props}>\n      {/* Toolbar: one surface that is the search, the selection or the undo. */}\n      <div\n        onPointerEnter={() => setHoldUndo(true)}\n        onPointerLeave={() => setHoldUndo(false)}\n        onFocus={() => setHoldUndo(true)}\n        onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setHoldUndo(false)}\n        className=\"relative h-11 overflow-hidden rounded-[14px] border border-border transition-colors duration-300\"\n        style={{ background: mode === \"search\" ? \"var(--background)\" : \"color-mix(in oklab, var(--primary) 6%, var(--background))\" }}\n      >\n        <div className=\"absolute inset-0 flex items-center gap-2 pl-3 pr-2\" style={swap(mode === \"search\", reduced, mode === \"search\" ? 0 : -8)} inert={mode !== \"search\"}>\n          {searchable ? (\n            <label className=\"flex min-w-0 flex-1 items-center gap-2\">\n              <Search className=\"size-4 shrink-0 text-muted-foreground\" strokeWidth={1.75} aria-hidden=\"true\" />\n              <input\n                value={query}\n                onChange={(e) => {\n                  setQuery(e.target.value);\n                  setPage(0);\n                }}\n                placeholder={searchPlaceholder}\n                aria-label={searchPlaceholder}\n                aria-describedby={`${uid}-count`}\n                className=\"h-9 min-w-0 flex-1 bg-transparent text-[13.5px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n              />\n              <button\n                type=\"button\"\n                aria-label=\"Clear search\"\n                onClick={() => setQuery(\"\")}\n                className=\"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground\"\n                style={swap(!!query, reduced, 0)}\n                tabIndex={query ? 0 : -1}\n              >\n                <X className=\"size-3.5\" strokeWidth={1.75} />\n              </button>\n            </label>\n          ) : (\n            <span className=\"flex-1\" />\n          )}\n          {sortable.length > 0 && (\n            <label className=\"relative hidden h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] @max-[34rem]:flex\">\n              <span aria-hidden=\"true\">\n                {sortCol ? <TextMorph>{sortCol.header}</TextMorph> : \"Sort\"}\n              </span>\n              <ArrowUp className=\"ml-1 size-3\" strokeWidth={2} aria-hidden=\"true\" style={{ transform: sort?.dir === \"desc\" ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 420ms ${THROW}` }} />\n              <select\n                aria-label=\"Sort by\"\n                value={sort ? `${sort.key}:${sort.dir}` : \"\"}\n                onChange={(e) => {\n                  const [key, dir] = e.target.value.split(\":\");\n                  const next = key ? { key, dir: dir as \"asc\" | \"desc\" } : null;\n                  motion.stagger();\n                  if (sortProp === undefined) setSortOwn(next);\n                  onSortChange?.(next);\n                }}\n                className=\"absolute inset-0 cursor-pointer opacity-0\"\n              >\n                <option value=\"\">Unsorted</option>\n                {sortable.flatMap((c) => [\n                  <option key={`${c.key}:asc`} value={`${c.key}:asc`}>{`${c.header}, ascending`}</option>,\n                  <option key={`${c.key}:desc`} value={`${c.key}:desc`}>{`${c.header}, descending`}</option>,\n                ])}\n              </select>\n            </label>\n          )}\n          <span id={`${uid}-count`} className=\"shrink-0 whitespace-nowrap pr-1 text-[12px] tabular-nums text-muted-foreground @max-[34rem]:hidden\">\n            <NumberRoll value={sorted.length} /> <TextMorph>{count(sorted.length)}</TextMorph>\n          </span>\n        </div>\n\n        <div className=\"absolute inset-0 flex items-center gap-1 pl-1.5 pr-1.5\" style={swap(mode === \"selection\", reduced, mode === \"search\" ? 8 : -8)} inert={mode !== \"selection\"}>\n          <button type=\"button\" aria-label=\"Clear selection\" onClick={() => setSelected([])} className={`flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${FOCUS}`}>\n            <X className=\"size-4\" strokeWidth={1.75} />\n          </button>\n          <span className=\"mr-auto whitespace-nowrap text-[13px] font-medium tabular-nums\">\n            <NumberRoll value={Math.max(1, selected.size)} /> selected\n          </span>\n          {actions.map((a) => (\n            <StatusButton\n              key={a.id}\n              variant=\"ghost\"\n              size=\"sm\"\n              status={actionStatus[a.id] ?? \"idle\"}\n              labels={{ idle: a.label, pending: a.progress?.[0] ?? a.label, success: a.progress?.[1] ?? \"Done\", error: \"Try again\" }}\n              onClick={() => run(a)}\n              onReset={() => setActionStatus((s) => ({ ...s, [a.id]: \"idle\" }))}\n              className={a.tone === \"destructive\" && (actionStatus[a.id] ?? \"idle\") === \"idle\" ? \"!text-red-600 hover:!bg-red-500/10 dark:!text-red-400\" : \"\"}\n            />\n          ))}\n        </div>\n\n        <div className=\"absolute inset-0 flex items-center gap-2 pl-3 pr-1.5\" style={swap(mode === \"undo\", reduced, 8)} inert={mode !== \"undo\"}>\n          <span className=\"mr-auto whitespace-nowrap text-[13px] tabular-nums\">\n            {undo && (\n              <>\n                <NumberRoll value={undo.ids.length} /> {count(undo.ids.length)} {undo.action.progress?.[1]?.toLowerCase() ?? \"removed\"}\n              </>\n            )}\n          </span>\n          <button type=\"button\" onClick={takeBack} className={`inline-flex h-7 cursor-pointer items-center rounded-full px-3 text-[12px] font-medium hover:bg-accent ${FOCUS}`}>\n            Undo\n          </button>\n          {undo && (\n            // The chance to undo, as a hairline that drains; hovering holds it.\n            <span\n              key={undo.key}\n              aria-hidden=\"true\"\n              className=\"absolute inset-x-0 bottom-0 h-px origin-left bg-foreground/40\"\n              style={{ animation: `ui-progress ${undoFor}ms linear reverse forwards`, animationPlayState: holdUndo ? \"paused\" : \"running\" }}\n              onAnimationEnd={() => commitUndo(undo)}\n            />\n          )}\n        </div>\n      </div>\n\n      {/* Notice: one line that folds in when something went wrong. */}\n      <div className=\"grid transition-[grid-template-rows] duration-300\" style={{ gridTemplateRows: notice ? \"1fr\" : \"0fr\", transitionTimingFunction: EASE }}>\n        <div className=\"min-w-0 overflow-hidden\">\n          <p className=\"truncate pl-1 pt-2 text-[12.5px] text-red-600 dark:text-red-400\">{notice?.text ?? \" \"}</p>\n        </div>\n      </div>\n\n      <div ref={gridRef} role=\"grid\" aria-label={label} aria-rowcount={sorted.length + 1} aria-multiselectable={selectable || undefined} onKeyDown={onGridKey} className=\"mt-3 overflow-hidden rounded-[16px] border border-border\">\n        {/* Header */}\n        <div role=\"row\" className=\"grid h-10 items-center border-b border-border text-[12px] text-muted-foreground @max-[34rem]:hidden\" style={{ gridTemplateColumns: tracks }}>\n          {selectable && (\n            <span role=\"columnheader\" className=\"flex items-center justify-center\">\n              <button type=\"button\" aria-label={allState === \"on\" ? \"Clear selection\" : \"Select all\"} onClick={toggleAll} className={`flex size-8 cursor-pointer items-center justify-center rounded-[8px] hover:bg-accent ${FOCUS}`}>\n                <Box state={allState} reduced={reduced} />\n              </button>\n            </span>\n          )}\n          {columns.map((col) => {\n            const active = sort?.key === col.key;\n            return (\n              <span key={col.key} role=\"columnheader\" aria-sort={active ? (sort!.dir === \"asc\" ? \"ascending\" : \"descending\") : col.sortable ? \"none\" : undefined} className={`min-w-0 px-1.5 ${col.align === \"right\" ? \"text-right\" : \"\"}`}>\n                {col.sortable ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => sortBy(col.key)}\n                    className={`inline-flex h-7 max-w-full cursor-pointer items-center gap-1 rounded-[8px] px-1.5 transition-colors hover:bg-accent hover:text-foreground ${active ? \"text-foreground\" : \"\"} ${col.align === \"right\" ? \"flex-row-reverse\" : \"\"} ${FOCUS}`}\n                  >\n                    <span className=\"truncate\">{col.header}</span>\n                    <ArrowUp\n                      className=\"size-3 shrink-0\"\n                      strokeWidth={2}\n                      aria-hidden=\"true\"\n                      style={{\n                        opacity: active ? 1 : 0,\n                        transform: `${active && sort!.dir === \"desc\" ? \"rotate(180deg)\" : \"rotate(0deg)\"} scale(${active ? 1 : 0.6})`,\n                        transition: reduced ? \"none\" : `transform 460ms ${THROW}, opacity 200ms ${EASE}`,\n                      }}\n                    />\n                  </button>\n                ) : (\n                  <span className=\"block truncate px-1.5\">{col.header}</span>\n                )}\n              </span>\n            );\n          })}\n          {renderDetail && <span role=\"columnheader\" aria-label=\"Details\" />}\n        </div>\n\n        {/* Body: eases to its height; the page slides in from the side you went. */}\n        <div ref={wrapRef} className=\"overflow-clip\">\n          <div ref={bodyRef} className=\"relative\">\n            {pageRows.map((row, i) => {\n              const id = getRowId(row);\n              const isSelected = selected.has(id);\n              return (\n                <div\n                  key={id}\n                  data-row-id={id}\n                  role=\"row\"\n                  aria-rowindex={current * pageSize + i + 2}\n                  aria-selected={selectable ? isSelected : undefined}\n                  aria-expanded={renderDetail ? open.has(id) : undefined}\n                  tabIndex={cursor ? (cursor.id === id && cursor.col === -1 ? 0 : -1) : i === 0 ? 0 : -1}\n                  onFocus={(e) => e.target === e.currentTarget && setCursor({ id, col: -1 })}\n                  className={`relative border-b border-border transition-colors duration-200 last:border-b-0 ${CELL_FOCUS}`}\n                  style={{ background: isSelected ? \"color-mix(in oklab, var(--primary) 5%, var(--background))\" : \"var(--background)\" }}\n                >\n                  {renderRow(row)}\n                </div>\n              );\n            })}\n\n            {/* Rows that went fold away where they stood. */}\n            {motion.leaving.map((g) => {\n              const row = lastSeen.current.get(g.id);\n              if (!row) return null;\n              return (\n                <div\n                  key={g.key}\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-x-0 overflow-hidden border-b border-border bg-background\"\n                  style={{ top: g.top, height: g.height }}\n                  ref={(el) => {\n                    if (!el || el.dataset.out) return;\n                    el.dataset.out = \"1\";\n                    // Sorted off the page: it slides off toward its new rank. Gone from the list: it folds away here.\n                    el.animate(\n                      g.dy\n                        ? [\n                            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n                            { transform: `translateY(${g.dy}px)`, opacity: 0, filter: \"blur(3px)\" },\n                          ]\n                        : [\n                            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n                            { opacity: 0, transform: \"scale(0.985)\", filter: \"blur(4px)\" },\n                          ],\n                      { duration: g.dy ? 360 : 280, easing: EASE, fill: \"forwards\" },\n                    ).onfinish = () => motion.done(g.key);\n                  }}\n                >\n                  {renderRow(row, true)}\n                </div>\n              );\n            })}\n\n            {/* Nothing matches: said in the same place, the same height as a row. */}\n            <div className=\"grid transition-[grid-template-rows,opacity] duration-300\" style={{ gridTemplateRows: pageRows.length ? \"0fr\" : \"1fr\", opacity: pageRows.length ? 0 : 1, transitionTimingFunction: EASE }} aria-hidden={!!pageRows.length}>\n              <div className=\"min-h-0 overflow-hidden\">\n                <p className=\"px-4 py-6 text-center text-[13px] text-muted-foreground\">\n                  <TextMorph animateWidth={false}>{q ? `No ${noun[1]} match “${query.trim()}”` : `No ${noun[1]} yet`}</TextMorph>\n                </p>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Pages */}\n      {pages > 1 && (\n        <div className=\"mt-2.5 flex items-center justify-between gap-2 px-1 text-[12px] tabular-nums text-muted-foreground\">\n          <span className=\"inline-flex items-baseline\">\n            <NumberRoll value={from} />–<NumberRoll value={to} />\n            &nbsp;of&nbsp;\n            <NumberRoll value={sorted.length} />\n          </span>\n          <span className=\"flex items-center gap-0.5\">\n            <button type=\"button\" aria-label=\"Previous page\" disabled={current === 0} onClick={() => goPage(-1)} className={`flex size-7 cursor-pointer items-center justify-center rounded-full hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40 ${FOCUS}`}>\n              <ChevronLeft className=\"size-4\" strokeWidth={1.75} />\n            </button>\n            <button type=\"button\" aria-label=\"Next page\" disabled={current >= pages - 1} onClick={() => goPage(1)} className={`flex size-7 cursor-pointer items-center justify-center rounded-full hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40 ${FOCUS}`}>\n              <ChevronRight className=\"size-4\" strokeWidth={1.75} />\n            </button>\n          </span>\n        </div>\n      )}\n\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n"},{"path":"registry/use-row-motion.ts","type":"registry:component","content":"// Hairline UI · data-table · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * ROW MOTION: rows that keep their place on screen\n *\n *   glide     after any render (a sort, a filter, new data from\n *             the server) every row that moved glides from its\n *             old place to its new one. Glides are additive, so\n *             a change that lands mid-glide adds its own offset\n *             instead of restarting anything\n *   travel    a row that sorts onto the page slides in from the\n *             side where it ranked before; one that sorts off\n *             slides out toward its new rank\n *   enter     a row that's new to the list fades in where it\n *             lands while the rows below glide clear of it\n *   leave     a row that's gone from the list is handed back\n *             with its last box, so a copy can fold away there\n *   stagger   a sort can ask for the rows to set off one after\n *             another, top first\n *\n * Positions are layout offsets (transforms ignored), kept after\n * every render and whenever the list changes size (a row\n * opening its detail), so they're never stale. Rows are found\n * by data-row-id; ranks are their places in the whole sorted\n * list, not just the page.\n * ───────────────────────────────────────────────────────── */\n\n/** dy: 0 to fold in place, or how far its copy should slide (it sorted off the page). */\nexport type LeavingRow = { id: string; top: number; height: number; dy: number; key: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = 480;\nlet seq = 0;\n\nexport function useRowMotion(\n  container: React.RefObject<HTMLElement | null>,\n  { reduced, resetKey, ranks }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number> },\n) {\n  const pos = React.useRef(new Map<string, { top: number; height: number }>());\n  const lastRanks = React.useRef(ranks);\n  const lastKey = React.useRef(resetKey);\n  const staggerNext = React.useRef(false);\n  const [leaving, setLeaving] = React.useState<LeavingRow[]>([]);\n\n  const read = React.useCallback(() => {\n    const out = new Map<string, { top: number; height: number }>();\n    container.current?.querySelectorAll<HTMLElement>(\"[data-row-id]\").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight }));\n    return out;\n  }, [container]);\n\n  React.useLayoutEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const before = pos.current;\n    const after = read();\n    const prevRanks = lastRanks.current;\n    pos.current = after;\n    lastRanks.current = ranks;\n    const stagger = staggerNext.current;\n    staggerNext.current = false;\n    if (lastKey.current !== resetKey) {\n      // A different page: the caller slides the whole body; nothing glides and nothing folds.\n      lastKey.current = resetKey;\n      setLeaving((l) => (l.length ? [] : l));\n      return;\n    }\n    if (reduced || !before.size) return;\n\n    // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a\n    // quarter at most), so the list never shows holes while they travel.\n    const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b);\n    const rowH = heights[Math.floor(heights.length / 2)] ?? 48;\n    const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy));\n\n    let order = 0;\n    el.querySelectorAll<HTMLElement>(\"[data-row-id]\").forEach((row) => {\n      const id = row.dataset.rowId!;\n      const was = before.get(id);\n      const now = after.get(id)!;\n      const delay = stagger ? Math.min(order++ * 16, 200) : 0;\n      if (was) {\n        const dy = was.top - now.top;\n        if (Math.abs(dy) < 0.5) return;\n        row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: \"translateY(0px)\", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: \"add\", fill: \"backwards\" });\n        return;\n      }\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      if (from !== undefined && to !== undefined) {\n        // It ranked somewhere off the page: it comes in from that side.\n        const dy = clamp((from - to) * rowH);\n        row.animate(\n          [\n            { transform: `translateY(${dy}px)`, opacity: 0, filter: \"blur(3px)\" },\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n          ],\n          { duration: GLIDE, delay: delay / 2, easing: EASE, fill: \"backwards\" },\n        );\n        return;\n      }\n      row.animate(\n        [\n          { opacity: 0, filter: \"blur(4px)\" },\n          { opacity: 1, filter: \"blur(0px)\" },\n        ],\n        { duration: 420, delay: 90, easing: EASE, fill: \"backwards\" },\n      );\n    });\n\n    const gone = [...before].filter(([id]) => !after.has(id));\n    if (gone.length)\n      setLeaving((l) => [\n        ...l,\n        ...gone.map(([id, b]) => {\n          const from = prevRanks.get(id);\n          const to = ranks.get(id);\n          // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds.\n          const dy = from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0;\n          return { id, top: b.top, height: b.height, dy, key: ++seq };\n        }),\n      ]);\n  });\n\n  // The list changed size without a render of ours (a detail opening by transition): keep the positions true.\n  React.useEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      pos.current = read();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [container, read]);\n\n  return {\n    leaving,\n    /** The copy of a leaving row has gone. */\n    done: React.useCallback((key: number) => setLeaving((l) => l.filter((x) => x.key !== key)), []),\n    /** The next change (a sort) sets the rows off one after another. */\n    stagger: () => {\n      staggerNext.current = true;\n    },\n  };\n}\n"}],"css":{"@keyframes ui-progress":{"from":{"transform":"scaleX(0)"},"to":{"transform":"scaleX(1)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/data-table. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}