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)",
// 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>
);
}