CellFlash
A cell that tints its own token colour when live data changes under it. The tint sits on negative margins, so a flashing figure never nudges the ones beside it.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/cell-flash.jsonLive · DOMlive state
api.gateway1,284
worker.queue862
edge.cache2,310
auth.session415
req/s · streaming"use client";
import * as React from "react";
import { cx } from "@pouriahlabs/wisp-ui";
/**
* CellFlash — a value that tints its own cell when live data changes under it.
*
* On a dashboard that streams, the hard problem is not rendering the new number
* — React does that for free — it is that the reader never notices it happened.
* Three figures on a screen quietly become different figures, and the one that
* mattered went by unread.
*
* So the flash is the whole component: a brief wash of the semantic token,
* decaying to nothing, on whichever cells actually moved. Direction is inferred
* when the watched value is numeric, so the common case is zero configuration —
* a rise tints with `--wisp-up`, a fall with `--wisp-down`, and anything
* non-numeric with the brand accent.
*
* The tint is painted on a padded box with matching negative margins, so the
* highlight has room to breathe without occupying a single pixel of layout.
* Nothing around a flashing cell moves.
*
* Under reduced motion the marker still appears — it is applied and removed
* instantly rather than fading. Suppressing it entirely would mean a reader who
* asked for less motion is the one reader never told the data changed, which is
* the opposite of an accommodation.
*
* The first render never flashes. A table mounting is not new data arriving,
* and lighting up every row on load teaches people to ignore the signal.
*/
export interface CellFlashProps extends React.HTMLAttributes<HTMLElement> {
/**
* The identity of the current value. When this changes, the cell flashes.
* Usually the value itself; pass a version or timestamp when the displayed
* text can repeat and you still want the update marked.
*/
watch: string | number;
/**
* `1` tints up, `-1` tints down, `0` uses the brand accent. Inferred from the
* change when `watch` is numeric.
*/
direction?: 1 | -1 | 0;
/** How long the tint lasts, ms. */
duration?: number;
as?: React.ElementType;
children: React.ReactNode;
}
export function CellFlash({
watch,
direction,
duration = 760,
as: Tag = "span",
className,
style,
children,
...props
}: CellFlashProps) {
const ref = React.useRef<HTMLElement>(null);
const previous = React.useRef<string | number | undefined>(undefined);
React.useEffect(() => {
const node = ref.current;
const was = previous.current;
previous.current = watch;
// Mount is not an update.
if (!node || was === undefined || was === watch) return;
const inferred =
typeof watch === "number" && typeof was === "number" ? Math.sign(watch - was) : 0;
const dir = direction ?? inferred;
node.dataset.wispFlash = dir > 0 ? "up" : dir < 0 ? "down" : "flat";
// Removing the class, forcing a reflow and re-adding is what restarts a CSS
// animation. Without the reflow the browser coalesces both mutations into
// one style recalculation and the animation never re-runs, so a second
// change inside the decay window would silently go unmarked.
node.classList.remove("is-flash");
void node.offsetWidth;
node.classList.add("is-flash");
// `animationend` resolves the flash off the actual CSS animation instead
// of a parallel JS clock, so it can't drift from `--wisp-flash-duration`.
// It never fires under reduced motion or `data-wisp-motion="off"`, where
// the animation is `none` — the timer is the fallback for that case, and
// just loses the race to the event the rest of the time. `settled` stops
// whichever loses the race from acting a second time.
let settled = false;
const settle = (event?: AnimationEvent) => {
if (settled || (event && (event.target !== node || event.animationName !== "wisp-flash")))
return;
settled = true;
node.classList.remove("is-flash");
node.removeEventListener("animationend", settle);
clearTimeout(timer);
};
node.addEventListener("animationend", settle);
const timer = setTimeout(settle, duration);
// Only `watch` is a dependency — `direction` and `duration` are read
// fresh from the closure the next time `watch` actually changes. Keying
// the effect off them too meant a mid-flash prop change reran this body,
// hit the "nothing changed" guard above, and returned before the removal
// above was ever (re)armed, stranding `is-flash` for good.
return () => {
settle();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watch]);
return (
<Tag
ref={ref}
className={cx("wisp-flash", className)}
style={{ "--wisp-flash-duration": `${duration}ms`, ...style } as React.CSSProperties}
{...props}
>
{children}
</Tag>
);
}
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