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

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.