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)",
  // emerald-500 — no success token exists; alpha keeps it soft in both themes.
  positive: "oklch(0.723 0.219 149.58 / 0.16)",
  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.
 *
 * Reduced motion skips the wash; the text still changes instantly, marked
 * with a brief bold instead.
 */
export function HighlightOnUpdate({
  value,
  color = "neutral",
  className,
  children,
  ...props
}: HighlightOnUpdateProps) {
  const reducedMotion = useReducedMotion() ?? false;
  const [flash, setFlash] = React.useState(false);
  const [boldFlash, setBoldFlash] = React.useState(false);
  const previous = React.useRef(value);
  const washTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);
  const boldTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);

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

  React.useEffect(() => {
    return () => {
      if (washTimeout.current) clearTimeout(washTimeout.current);
      if (boldTimeout.current) clearTimeout(boldTimeout.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",
          transitionDuration: flash ? "200ms" : "800ms",
          transitionTimingFunction: "var(--ease-out)",
        }}
      />
      <span className={cn("relative", boldFlash && "font-semibold")}>
        {children}
      </span>
    </span>
  );
}