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.
npx shadcn@latest add @paragon/highlight-on-update"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>
);
}