Highlight On Update
Text Effects

Highlight On Update

Wraps a live value and flashes a soft background wash — 200ms in, 800ms decay — whenever the watched value changes, with positive and negative color intents.

Install

npx shadcn@latest add @paragon/highlight-on-update

highlight-on-update.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const WASH: Record<"neutral" | "positive" | "negative", string> = {
  neutral: "color-mix(in oklab, var(--color-foreground) 9%, transparent)",
  positive: "color-mix(in oklab, var(--color-success) 16%, transparent)",
  negative: "color-mix(in oklab, var(--color-destructive) 16%, transparent)",
};

export interface HighlightOnUpdateProps extends React.ComponentProps<"span"> {
  /** The datum to watch. A change (Object.is) triggers the flash. */
  value: unknown;
  /** Wash intent, e.g. green for gains, red for regressions. */
  color?: "neutral" | "positive" | "negative";
}

/**
 * Live-value wrapper that flashes a soft background wash — 200ms in, 800ms
 * decay — whenever `value` changes. For dashboards streaming prices, counts,
 * and latencies. The wash is an opacity-only overlay (no background-color
 * animation), interruptible: rapid updates retarget the fade rather than
 * restarting it.
 *
 * Because the wash is pure opacity/color with zero movement, it stays on
 * under prefers-reduced-motion (house policy: remove movement, keep
 * opacity/color) — reduced-motion users still see which value changed, and
 * nothing ever shifts layout.
 */
export function HighlightOnUpdate({
  value,
  color = "neutral",
  className,
  children,
  ...props
}: HighlightOnUpdateProps) {
  const reducedMotion = useReducedMotion() ?? false;
  const [flash, setFlash] = React.useState(false);
  const previous = React.useRef(value);
  const washTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(() => {
    if (Object.is(previous.current, value)) return;
    previous.current = value;
    setFlash(true);
    if (washTimeout.current) clearTimeout(washTimeout.current);
    washTimeout.current = setTimeout(() => setFlash(false), 200);
  }, [value]);

  React.useEffect(() => {
    return () => {
      if (washTimeout.current) clearTimeout(washTimeout.current);
    };
  }, []);

  return (
    <span className={cn("relative inline-block", className)} {...props}>
      <span
        aria-hidden
        className="pointer-events-none absolute -inset-x-1.5 -inset-y-0.5 rounded-md"
        style={{
          backgroundColor: WASH[color],
          opacity: flash ? 1 : 0,
          transitionProperty: "opacity",
          // Reduced motion: appear instantly, fade a touch quicker.
          transitionDuration: reducedMotion
            ? flash
              ? "0ms"
              : "400ms"
            : flash
              ? "200ms"
              : "800ms",
          transitionTimingFunction: "var(--ease-out)",
        }}
      />
      <span className="relative">{children}</span>
    </span>
  );
}