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.json
Live · DOMlive state
api.gateway1,284
worker.queue862
edge.cache2,310
auth.session415
req/s · streaming

CellFlashesc or double-click to exit full screendouble-tap to exit

data/cell-flash.tsx
"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