FlashCell
A cell that flashes on change, colored up, down, or flat, as a fill or an inset ring.
import { useState } from "react"
import { FlashCell } from "@/components/ui/flash-cell"
export default function FlashCellDemo() {
const [quote, setQuote] = useState({ price: 100, change: 0 })
function receive(change: number) {
setQuote((previous) => ({ price: previous.price + change, change }))
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => receive(0.25)}>Price +0.25</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => receive(-0.25)}>Price −0.25</button>
</div>
<div className="w-28 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<p className="text-muted-foreground">Price</p>
<FlashCell value={quote.price} className="px-2 py-1 text-right">{quote.price.toFixed(2)}</FlashCell>
<p role="status">{quote.change === 0 ? "No update yet" : quote.change > 0 ? "Up 0.25" : "Down 0.25"}</p>
</div>
</>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/flash-cell#v2.0.0
npx shadcn@latest add tradecn/ui/flash-cell#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/flash-cell#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/flash-cell#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Copy the files into your project:
components/ui/ flash-cell.tsx import { cn } from "cn" import { useCallback, useRef, type ComponentProps, type ReactNode } from "react" import { useFlash, type FlashOptions } from "@/hooks/use-flash" export interface FlashCellProps extends Omit<ComponentProps<"div">, "children">, FlashOptions { /** The value to watch. A change flashes the cell, colored by direction. */ value: unknown children?: ReactNode } /** * A cell that flashes when `value` changes: up, down, or flat, as a background fill or an inset ring. * Applies to the cell itself, not a span inside it. Zero change is flat. Reduced motion shows a * static color for the window instead of an animation. */ export function FlashCell({ value, children, className, windowMs, variant = "fill", compare, flashOnEqual, memory, cellKey, disabled, now, revision, ref: forwardedRef, ...rest }: FlashCellProps) { const ref = useRef<HTMLDivElement>(null) useFlash(ref, value, { windowMs, variant, compare, flashOnEqual, memory, cellKey, disabled, now, revision }) // A trigger's asChild, a render prop, or a spreading wrapper injects a ref of its own; it // merges with the flash target instead of silently replacing it. const rootRef = useCallback((node: HTMLDivElement | null) => { ref.current = node if (typeof forwardedRef === "function") { const cleanup = forwardedRef(node) return () => { if (typeof cleanup === "function") cleanup() else forwardedRef(null) } } if (forwardedRef) forwardedRef.current = node return () => { if (forwardedRef && typeof forwardedRef !== "function") forwardedRef.current = null } }, [forwardedRef]) return ( <div ref={rootRef} data-slot="tradecn-flash-cell" data-variant={variant} data-numeric="" className={cn( "lining-nums tabular-nums", // The static color under reduced motion, and behind the animation while it runs. variant === "fill" ? "data-[direction=up]:bg-up-soft data-[direction=down]:bg-down-soft data-[direction=flat]:bg-flat-soft" : "data-[direction=up]:shadow-[inset_0_0_0_1px_var(--up)] data-[direction=down]:shadow-[inset_0_0_0_1px_var(--down)] data-[direction=flat]:shadow-[inset_0_0_0_1px_var(--flat)]", className, )} {...rest} > {children} </div> ) }hooks/use-flash.ts import { useLayoutEffect, useRef, type RefObject } from "react" // Flash a cell when its value changes, colored by direction, without a React re-render per tick. // // The previous value lives outside React (per cell, or in a shared FlashMemory keyed by cell so a // virtualized row that scrolls back within the window resumes its flash instead of losing it). // The flash itself is a Web Animations API animation on the existing element: retriggering cancels // the previous one for free, and nothing is remounted. export type Direction = "up" | "down" | "flat" export interface FlashRecord { value: unknown /** Clock time of the last flash, or -Infinity when the cell has never flashed. */ at: number dir: Direction } export interface FlashMemory { get(key: string): FlashRecord | undefined set(key: string, record: FlashRecord): void /** Drop every record whose key starts with `prefix` (a grid calls it with the row id on remove). */ forget(prefix: string): void readonly size: number } /** Bounded, least-recently-set memory. `max` records; oldest are evicted first. */ export function createFlashMemory(max = 50_000): FlashMemory { const map = new Map<string, FlashRecord>() return { get: (key) => map.get(key), set(key, record) { if (map.has(key)) map.delete(key) map.set(key, record) if (map.size > max) map.delete(map.keys().next().value!) }, forget(prefix) { for (const key of map.keys()) if (key.startsWith(prefix)) map.delete(key) }, get size() { return map.size }, } } export type Compare = (prev: unknown, next: unknown) => number | null /** Numeric difference when both sides are finite numbers; 0 when identical; null for any other change. */ export const compareValues: Compare = (prev, next) => { if (typeof prev === "number" && typeof next === "number" && Number.isFinite(prev) && Number.isFinite(next)) return next - prev return Object.is(prev, next) ? 0 : null } /** Direction of a change. Zero is flat, never up. A non-numeric change is flat. */ export function directionOf(prev: unknown, next: unknown, compare: Compare = compareValues): Direction { const d = compare(prev, next) if (d === null || d === 0) return "flat" return d > 0 ? "up" : "down" } export interface FlashOptions { /** How long a flash lasts, and how long a scrolled-away cell remembers it. Default 900. */ windowMs?: number /** `fill` tints the background; `ring` draws an inset outline, for cells that already carry a bar. */ variant?: "fill" | "ring" /** Paint with this instead of the direction's token: `var(--primary)` for an acknowledgement that has no direction. */ color?: string compare?: Compare /** * Flash flat when the same value arrives again. Needs `revision` to know it arrived: pass the row object * or the batch version, since an equal `value` alone looks unchanged to React. Default false. */ flashOnEqual?: boolean /** Anything that changes identity when a new reading arrives (the row object, a store version). */ revision?: unknown /** Shared memory across mounts; requires `cellKey`. */ memory?: FlashMemory cellKey?: string disabled?: boolean /** Injectable clock in ms; defaults to performance.now. */ now?: () => number } export const FILL_COLORS: Record<Direction, string> = { up: "var(--up-soft)", down: "var(--down-soft)", flat: "var(--flat-soft)", } export const RING_COLORS: Record<Direction, string> = { up: "var(--up)", down: "var(--down)", flat: "var(--flat)", } /** Tailwind classes for coloring a value by direction: text-up, text-down, text-flat. */ export function directionClass(dir: Direction | null | undefined): string { return dir === "up" ? "text-up" : dir === "down" ? "text-down" : "text-flat" } const animations = new WeakMap<Element, Animation>() const clearTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>() let reducedMotion: boolean | null = null function prefersReducedMotion(): boolean { if (reducedMotion === null) { reducedMotion = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)").matches : false } return reducedMotion } /** Test hook: forget the cached media query. */ export function resetReducedMotionCache() { reducedMotion = null } function keyframes(dir: Direction, variant: "fill" | "ring", color?: string): Keyframe[] { if (variant === "ring") return [{ boxShadow: `inset 0 0 0 1px ${color ?? RING_COLORS[dir]}` }, { boxShadow: "inset 0 0 0 1px transparent" }] return [{ backgroundColor: color ?? FILL_COLORS[dir] }, { backgroundColor: "transparent" }] } /** Start (or resume, `elapsed` ms in) a flash on an element. */ export function playFlash(el: HTMLElement, dir: Direction, opts: { windowMs: number; variant: "fill" | "ring"; color?: string; elapsed?: number }) { const elapsed = Math.max(0, Math.min(opts.elapsed ?? 0, opts.windowMs)) animations.get(el)?.cancel() const pending = clearTimers.get(el) if (pending) clearTimeout(pending) el.dataset.direction = dir const done = () => { if (el.dataset.direction === dir) delete el.dataset.direction } if (prefersReducedMotion() || typeof el.animate !== "function") { // Static color from the data-direction styles, cleared when the window ends. clearTimers.set(el, setTimeout(done, opts.windowMs - elapsed)) return } const anim = el.animate(keyframes(dir, opts.variant, opts.color), { duration: opts.windowMs, easing: "ease-out", fill: "none" }) anim.currentTime = elapsed anim.onfinish = () => { if (animations.get(el) === anim) animations.delete(el) done() } animations.set(el, anim) } const NEVER: FlashRecord = { value: undefined, at: -Infinity, dir: "flat" } export function useFlash<E extends HTMLElement>(ref: RefObject<E | null>, value: unknown, opts: FlashOptions = {}): void { const { windowMs = 900, variant = "fill", color, compare = compareValues, flashOnEqual = false, memory, cellKey, disabled = false, revision } = opts const now = opts.now ?? (() => performance.now()) const local = useRef<FlashRecord | undefined>(undefined) const key = memory ? cellKey : undefined if (memory && !cellKey) throw new Error("useFlash: a shared memory needs a cellKey") const read = (): FlashRecord | undefined => (key !== undefined ? memory!.get(key) : local.current) const write = (record: FlashRecord) => { if (key !== undefined) memory!.set(key, record) else local.current = record } useLayoutEffect(() => { const el = ref.current if (!el || disabled) return const t = now() const prev = read() if (!prev) { // First sight of this cell: remember, do not flash. write({ ...NEVER, value }) return } const changed = !Object.is(prev.value, value) if (changed || flashOnEqual) { const dir = changed ? directionOf(prev.value, value, compare) : "flat" write({ value, at: t, dir }) playFlash(el, dir, { windowMs, variant, color }) return } // Same value on a fresh mount (a virtualized row scrolled back): resume a flash still in its window. const elapsed = t - prev.at if (elapsed < windowMs) playFlash(el, prev.dir, { windowMs, variant, color, elapsed }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, revision, ref, disabled, windowMs, variant, color, flashOnEqual, compare, memory, key]) useLayoutEffect(() => { const el = ref.current return () => { if (!el) return animations.get(el)?.cancel() animations.delete(el) const pending = clearTimers.get(el) if (pending) clearTimeout(pending) } }, [ref]) } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); }
Usage
import { useState } from "react"
import { FlashCell } from "@/components/ui/flash-cell"
function PriceCell() {
const [quote, setQuote] = useState({ price: 100, change: 0 })
function receive(change: number) {
setQuote((previous) => ({ price: previous.price + change, change }))
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => receive(0.25)}>Price +0.25</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => receive(-0.25)}>Price −0.25</button>
</div>
<div className="w-28 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<p className="text-muted-foreground">Price</p>
<FlashCell value={quote.price} className="px-2 py-1 text-right">{quote.price.toFixed(2)}</FlashCell>
<p role="status">{quote.change === 0 ? "No update yet" : quote.change > 0 ? "Up 0.25" : "Down 0.25"}</p>
</div>
</>
)
}
value is watched, not printed. Supply the display as children; this example formats a supplied decimal price with toFixed(2). Use an instrument formatter from format when the price needs another notation. Without children, the cell is empty.
Choose either price control to send a quarter-point change. The first render is quiet; later increases and decreases flash for the default 900 ms. The text below the price shows the direction immediately and remains after the color clears.
Fill, ring, and repeated readings
Both cells receive the same price. Fill tints the background; ring draws an inset outline. The controls supply positive, negative, or unchanged readings, and the caption records which reading arrived.
Repeat price changes revision while keeping value equal. With flashOnEqual, that starts a flat flash. The example enables equal-value flashes only after a reading arrives, keeping the initial render quiet under development Strict Mode effect replay. Supplying the same value alone would not signal a new arrival.
import { useState } from "react"
import { FlashCell } from "@/components/ui/flash-cell"
export default function FlashCellReadingsDemo() {
const [reading, setReading] = useState({ price: 100, change: 0, revision: 0 })
function receive(change: number) {
setReading((previous) => ({ price: previous.price + change, change, revision: previous.revision + 1 }))
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => receive(0.25)}>Both +0.25</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => receive(-0.25)}>Both −0.25</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => receive(0)}>Repeat price</button>
</div>
<table className="w-52 max-w-full text-xs lining-nums tabular-nums">
<caption className="caption-bottom pt-2"><span role="status">Reading {reading.revision}: {reading.revision === 0 ? "no update yet" : reading.change === 0 ? "unchanged" : reading.change > 0 ? "up 0.25" : "down 0.25"}</span></caption>
<thead><tr>{["Fill", "Ring"].map((label) => <th key={label} scope="col" className="pb-2 text-left font-normal text-muted-foreground">{label}</th>)}</tr></thead>
<tbody><tr>{(["fill", "ring"] as const).map((variant) => (
<td key={variant} className="w-1/2">
<FlashCell value={reading.price} revision={reading.revision} flashOnEqual={reading.revision > 0} variant={variant} className="px-2 py-1 text-right">{reading.price.toFixed(2)}</FlashCell>
</td>
))}</tr></tbody>
</table>
</>
)
}
Remembering a flash across mounts
Choose Raise both prices, then Hide cells and Show cells within five seconds. Both cells unmount while hidden. The local cell forgets its flash; the shared cell resumes the part of its original window that remains. After that window expires, showing the same price is quiet in both cells.
This example extends windowMs to 5000 so there is time to hide and restore the cells. The parent owns one bounded memory record and keeps the price while the cells are absent. Each shared cell needs a stable, distinct cellKey; here there is only one. The price control is disabled while hidden so the comparison isolates a same-value remount.
import { useState } from "react"
import { createFlashMemory } from "@/hooks/use-flash"
import { FlashCell } from "@/components/ui/flash-cell"
export default function FlashCellMemoryDemo() {
const [memory] = useState(() => createFlashMemory(1))
const [price, setPrice] = useState(100)
const [visible, setVisible] = useState(true)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1 disabled:opacity-50" disabled={!visible} onClick={() => setPrice((previous) => previous + 0.25)}>Raise both prices</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setVisible((previous) => !previous)}>{visible ? "Hide cells" : "Show cells"}</button>
</div>
<table className="w-52 max-w-full text-xs lining-nums tabular-nums">
<caption className="caption-bottom pt-2"><span role="status">{price === 100 ? "No update yet" : "Last change: up 0.25"}</span></caption>
<thead><tr><th scope="col" className="pb-2 text-left font-normal text-muted-foreground">Local memory</th><th scope="col" className="pb-2 text-left font-normal text-muted-foreground">Shared memory</th></tr></thead>
<tbody><tr>
<td className="w-1/2">{visible ? <FlashCell value={price} windowMs={5000} className="px-2 py-1 text-right">{price.toFixed(2)}</FlashCell> : <span className="block px-2 py-1 text-right text-muted-foreground">Hidden</span>}</td>
<td className="w-1/2">{visible ? <FlashCell value={price} windowMs={5000} memory={memory} cellKey="quote:price" className="px-2 py-1 text-right">{price.toFixed(2)}</FlashCell> : <span className="block px-2 py-1 text-right text-muted-foreground">Hidden</span>}</td>
</tr></tbody>
</table>
</>
)
}
API Reference
FlashCellProps extends the div's HTMLAttributes and FlashOptions, with value and optional children:
| Prop | Type | Default | Purpose |
|---|---|---|---|
value |
unknown |
Required | Value whose identity and direction are watched. |
children |
ReactNode |
Omitted | Content displayed inside the cell. |
windowMs |
number |
900 |
Flash duration and resume window, in milliseconds. Supply a finite positive duration. |
variant |
"fill" | "ring" |
"fill" |
Background tint or inset outline; a ring can sit around a cell that already carries a bar. |
compare |
(prev: unknown, next: unknown) => number | null |
compareValues |
Chooses direction after a value change. |
flashOnEqual |
boolean |
false |
Flash flat when the effect runs with the same value. |
revision |
unknown |
Omitted | Change its identity for a new reading whose value may be equal. |
memory |
FlashMemory |
Local ref | Retain previous values and flash times across mounts. |
cellKey |
string |
Omitted | Required, nonempty key when using shared memory; otherwise ignored. |
disabled |
boolean |
false |
Skip comparing, recording, and starting flashes. |
now |
() => number |
performance.now |
Clock in milliseconds; keep one time basis across shared-memory users. |
color |
string |
Omitted | Accepted by the inherited type, but the component forwards it to the div, not the hook. Use useFlash for a custom animation color. |
className |
string |
Omitted | Classes merged onto the div, including custom direction styles. |
Other div attributes and handlers are forwarded. The root carries data-slot="tradecn-flash-cell", data-variant, data-numeric, and lining, tabular figures.
Values and direction
The first observation records the value without flashing. Later changes use Object.is: new objects count as changed even with equal fields, while mutating the same object does not establish a new value. compare selects direction; it does not replace this identity check.
The default comparison subtracts prev from next when both are finite numbers. A positive difference is up, a negative difference is down, and zero or a non-numeric change is flat. Zero change is flat; a rise from -1 to 0 is up. Numeric strings are not parsed.
For repeated equal readings, set flashOnEqual and change revision, for example to the new row object or batch version:
<FlashCell value={quote.px} revision={quote} flashOnEqual>
{format.price(quote.px)}
</FlashCell>
An unchanged value and revision do not trigger the effect on an ordinary rerender. Changing another effect input, such as variant, windowMs, or compare, can also trigger it; with flashOnEqual, that starts a new flat flash. Keep comparator identity stable when it has not changed.
In development Strict Mode, effect replay can also start a flat flash on initial mount when flashOnEqual is enabled.
Timing and motion
The layout effect observes values after React commits. The hook adds no React state updates and does no feed batching; it cannot display intermediate values that never reach a committed render. For message-at-a-time feeds, batch updates before rendering, for example with createFrameBatcher.
Each new flash cancels the previous animation on the same element and starts over without remounting it. fill animates the soft token to transparent; ring animates a one-pixel inset shadow from the solid token to transparent. Direction-colored text on a fill can read below 4.5 to 1 in the light themes as the flash starts, until the tint fades, and for the whole window under reduced motion. Both use Web Animations with ease-out. data-direction stays set until the animation finishes, or until the fallback timer expires.
With reduced motion, or without element.animate, FlashCell shows its static direction color for the remaining window, then clears it. The reduced-motion preference is read on the first flash and cached; changing the system preference afterward does not update that cached value.
Setting disabled skips future effect work but does not cancel an active flash or update the remembered value. Re-enabling compares against the last recorded value. Unmounting cancels the element's animation and fallback timer.
Pair direction color with a sign or another text cue. formatSigned prints + or − for values that remain nonzero at the display precision; choose enough precision for the changes you need to show. directionClass adds matching text color but supplies no non-color cue. The cell adds no live announcement.
Shared memory
Keep one createFlashMemory() outside the virtualized cells and give each row/column pair a distinct, stable cellKey. A remount with the same value resumes an unexpired flash at its elapsed time; a changed value starts a new flash. With flashOnEqual, even a same-value remount starts a new flat flash.
windowMs limits resuming a flash, not retaining its record. Records remain until forgotten or evicted. createFlashMemory(max = 50_000) bounds the record count and evicts the least recently set key; reads do not refresh that order. Use a positive integer capacity.
FlashMemory member |
Type | Behavior |
|---|---|---|
get |
(key: string) => FlashRecord | undefined |
Read a record without refreshing its eviction order. |
set |
(key: string, record: FlashRecord) => void |
Store a record and make its key the newest. |
forget |
(prefix: string) => void |
Remove every key starting with the prefix. Include your row/column separator to avoid matching other row ids. |
size |
readonly number |
Current record count. |
FlashRecord contains value: unknown, at: number (last flash time, or -Infinity before the first flash), and dir: Direction. Direction is "up" | "down" | "flat". Local memory is lost on unmount; shared memory survives as long as its owner keeps it.
The hook
useFlash<E extends HTMLElement>(ref: RefObject<E | null>, value: unknown, options?: FlashOptions): void from @/hooks/use-flash flashes your own element. It takes value as its second argument and the flash options from the prop table as its third, including color. children and div attributes belong to the component. A caller ref — your own, or one a wrapping trigger injects — merges with the flash target instead of replacing it.
Give your element CSS for its data-direction states to make the static fallback visible. In reduced motion or without Web Animations, the hook sets only the attribute; it does not apply inline color. A custom color, such as var(--primary), changes animated keyframes only, so supply matching fallback styles yourself.
The same module exports these helpers:
| Helper | Returns | Behavior |
|---|---|---|
compareValues(prev: unknown, next: unknown) |
number | null |
Numeric difference for finite numbers; otherwise 0 for identical values and null for a change. |
directionOf(prev: unknown, next: unknown, compare = compareValues) |
Direction |
Positive comparison → up; zero or null → flat; otherwise down. Custom comparators should return a signed number, zero, or null, not NaN. |
directionClass(dir: Direction | null | undefined) |
string |
text-up, text-down, or text-flat; null and undefined map to flat. |
createFlashMemory(max = 50_000) |
FlashMemory |
Create the bounded memory described above. |
playFlash(el: HTMLElement, dir: Direction, opts) |
void |
Start or resume directly, without value tracking. Requires windowMs: number and variant: "fill" | "ring"; accepts color?: string and elapsed?: number (default 0, clamped to the window). |
Compare is the exported comparator type. FILL_COLORS and RING_COLORS are Record<Direction, string> maps to the soft and solid CSS tokens.
Tokens
The install adds the market tokens to your stylesheet, up, down, flat, and their -soft variants, usable as text-up, bg-down-soft, and so on. Your own values survive a later install.
This page is docs/flash-cell.md at v2.0.0.