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.jsonTable · DOMFLIP
CustomerStatusAmount
Adeyemi Colepaid$1,240
Priya Raghunathanpending$860
Tomas Lindqvistpaid$2,310
Wen Jiangrefunded$415
Sofia Marchettipaid$1,795
sorted by date
"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
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React