RowCascade · StatusPulse

Rows cascade in, then travel to their new positions when the sort changes — FLIP is measure-before-you-draw applied to layout.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/row-cascade.json
Table · DOMFLIP
CustomerStatusAmount
Adeyemi Colepaid$1,240
Priya Raghunathanpending$860
Tomas Lindqvistpaid$2,310
Wen Jiangrefunded$415
Sofia Marchettipaid$1,795
sorted by date

RowCascade · StatusPulseesc or double-click to exit full screendouble-tap to exit

data/row-cascade.tsx
"use client";

import * as React from "react";
import { cx, useFlip, useReducedMotion } from "@pouriahlabs/wisp-ui";

/**
 * RowCascade — rows that cascade in, and *travel* when the sort changes.
 *
 * FLIP is measure-before-you-draw applied to layout, which is why this belongs
 * in Wisp rather than in a table library: measure where every row is, let the
 * DOM reorder, measure again, apply the inverse offset, release it. The browser
 * animates `transform` only, on the compositor, and no layout is animated at
 * all.
 *
 * A note on markup: this renders a CSS grid, not a `<table>`. Transforms on
 * `<tr>` are unreliable across browsers — some ignore them, some create a
 * containing block that breaks sticky headers. If you need real table
 * semantics, apply the FLIP transform to a wrapper inside each cell instead.
 */

export interface RowCascadeColumn<T> {
  key: string;
  header: string;
  /** Grid track size for this column. */
  width?: string;
  align?: "start" | "end";
  /** Value used for sorting. Falls back to the rendered cell when that's a
   *  plain string or number, and to no ordering at all otherwise. */
  sortBy?: (row: T) => string | number;
  render: (row: T) => React.ReactNode;
}

export interface RowCascadeProps<T> extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  rows: readonly T[];
  columns: ReadonlyArray<RowCascadeColumn<T>>;
  /** Stable identity per row. */
  rowKey: (row: T, index: number) => string;
  /** Stagger between row reveals, ms. */
  stagger?: number;
  /** Column key to sort by. Controlled — change it and rows travel. */
  sort?: { key: string; direction: 1 | -1 } | null;
  /** Called when a header is clicked. Omit to disable sorting UI. */
  onSortChange?: (sort: { key: string; direction: 1 | -1 }) => void;
}

export function RowCascade<T>({
  rows,
  columns,
  rowKey,
  stagger = 110,
  sort = null,
  onSortChange,
  className,
  style,
  ...props
}: RowCascadeProps<T>) {
  const still = useReducedMotion();
  const flip = useFlip({ also: "opacity .34s ease" });

  const nodes = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
  const [revealed, setRevealed] = React.useState(0);

  const keys = rows.map((row, i) => rowKey(row, i));

  // Cascade in on mount.
  React.useEffect(() => {
    if (still) {
      setRevealed(rows.length);
      return;
    }
    setRevealed(0);
    const timers = rows.map((_, i) =>
      setTimeout(() => setRevealed((n) => Math.max(n, i + 1)), 120 + i * stagger),
    );
    return () => timers.forEach(clearTimeout);
  }, [rows.length, stagger, still]);

  const ordered = React.useMemo(() => {
    if (!sort) return rows.map((row, i) => ({ row, key: keys[i]! }));
    const column = columns.find((c) => c.key === sort.key);
    const value = (row: T) => {
      if (column?.sortBy) return column.sortBy(row);
      // No explicit `sortBy` — sort by whatever the cell actually shows, when
      // that's a plain string or number. A cell rendering a full element (an
      // icon, a chip) has nothing sortable to fall back to.
      const rendered = column?.render(row);
      return typeof rendered === "string" || typeof rendered === "number" ? rendered : "";
    };
    return rows
      .map((row, i) => ({ row, key: keys[i]! }))
      .sort((a, b) => {
        const x = value(a.row);
        const y = value(b.row);
        return (x < y ? -1 : x > y ? 1 : 0) * sort.direction;
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [rows, columns, sort?.key, sort?.direction]);

  // Play the FLIP on every order change. `ordered` has already reordered the
  // DOM by the time this runs (React committed the new `sort` prop) — flip()
  // inverts against its own cache of each row's last known position rather
  // than trying to measure a "before" here.
  React.useLayoutEffect(() => {
    flip(keys.map((key) => nodes.current.get(key)));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ordered]);

  const template = columns.map((c) => c.width ?? "1fr").join(" ");

  return (
    <div
      role="table"
      className={cx("wisp-rows", className)}
      style={{ "--wisp-rows-template": template, ...style } as React.CSSProperties}
      {...props}
    >
      <div className="wisp-rows-head" role="row">
        {columns.map((column) => (
          <span
            key={column.key}
            role="columnheader"
            aria-sort={
              sort?.key === column.key ? (sort.direction === 1 ? "ascending" : "descending") : undefined
            }
            className={cx("wisp-rows-header", column.align === "end" && "is-end")}
          >
            {onSortChange ? (
              <button
                type="button"
                className="wisp-rows-sort"
                onClick={() =>
                  onSortChange({
                    key: column.key,
                    direction: sort?.key === column.key && sort.direction === 1 ? -1 : 1,
                  })
                }
              >
                {column.header}
                {sort?.key === column.key ? (sort.direction === 1 ? " ▲" : " ▼") : ""}
              </button>
            ) : (
              column.header
            )}
          </span>
        ))}
      </div>

      {/* `role="row"` may sit directly under `table`, but not under a role-less
          div — this wrapper needs `rowgroup` or the body rows are orphaned. */}
      <div className="wisp-rows-body" role="rowgroup">
        {ordered.map(({ row, key }, position) => (
          <div
            key={key}
            role="row"
            ref={(node) => {
              nodes.current.set(key, node);
            }}
            className={cx("wisp-rows-row", position < revealed && "is-shown")}
          >
            {columns.map((column) => (
              <span
                key={column.key}
                role="cell"
                className={cx("wisp-rows-cell", column.align === "end" && "is-end")}
              >
                {column.render(row)}
              </span>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

/**
 * StatusPulse — a state chip where "in flight" breathes and settled states sit
 * still. Motion carries the state, so it survives being read at a glance and
 * doesn't rely on colour alone.
 */
export function StatusPulse({
  status,
  live = ["pending", "processing", "queued", "running"],
  className,
  ...props
}: React.HTMLAttributes<HTMLSpanElement> & { status: string; live?: readonly string[] }) {
  const isLive = live.includes(status.toLowerCase());
  return (
    <span
      className={cx("wisp-status", `is-${status.toLowerCase()}`, isLive && "wisp-pulse", className)}
      {...props}
    >
      {status}
    </span>
  );
}

Certified against the contract