Canvas Reveal Hover
A panel that answers the pointer with a seeded dot matrix on a canvas beneath its content — squares ignite outward from where the cursor crossed the border, and dissolve when it leaves.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/canvas-reveal-hover.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CanvasRevealHover" component — a panel
that lights up a dot matrix on a canvas UNDER its own content while the pointer
is over it (or while anything inside it has focus). Its only dependency is a
cn() class merger (clsx + tailwind-merge).
Contract
- export const CanvasRevealHover = React.forwardRef<HTMLDivElement, Props>;
Props extend React.HTMLAttributes<HTMLDivElement> (rest props spread onto the
root, ref goes to the root) plus:
- dotSize?: number (default 3) — side of one square dot in CSS px, clamped
0.5..40 and additionally never wider than the pitch.
- spacing?: number (default 12) — distance between dot centres in CSS px,
clamped 4..96. Widened automatically once the lattice would exceed the dot
budget.
- colors?: string[] (default ["chart-1","chart-2","chart-3"]) — theme token
names WITHOUT the leading `--`, capped at 8. Dots are assigned an ink
deterministically from the seed.
- speed?: number (default 1) — multiplier on the sweep and the fades, clamped
0.1..6. Deliberately cannot be 0: a reveal that never arrives is a bug, not
a setting.
- opacity?: number (default 0.75) — peak alpha of the whole layer.
- seed?: number (default 17) — integer seed for the ignition scatter, the
per-dot peak brightness and the ink map.
- active?: boolean — controlled override. While it is a boolean, hover and
focus are ignored entirely; leave it undefined to let the pointer and the
keyboard drive it, and pass `true` only while you want the field held open.
- disabled?: boolean (default false) — the layer never lights up and a lit one
fades out. The children are untouched either way.
- "use client": canvas, rAF, matchMedia, observers.
- Clamp every numeric prop up front and treat non-finite values as the default:
spacing 0 asks for infinite dots, a negative dot size hands the canvas an
inside-out rectangle, speed Infinity would make dt meaningless.
Behavior
- DOM: root div "group/reveal relative isolate overflow-hidden" holding (a) a
canvas that is aria-hidden, pointer-events-none, absolute inset-0, size-full —
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper for
children. The component paints no background of its own: the surface (bg-card,
the border, the radius) belongs to the consumer, and overflow-hidden is what
clips the lattice to it. Expose the state as data-state="revealed" | "idle" on
the root so descendants can react with group-data-[state=revealed]/reveal:.
- Lattice: cols = ceil(width / spacing), rows = ceil(height / spacing). If
cols * rows exceeds a hard budget (6000 dots), scale the pitch up by
sqrt(cols * rows / budget) and recompute — the cost stays bounded no matter
how fine a spacing a consumer asks for on a 1600px hero. Centre the lattice in
the box so the leftover remainder is split between both margins instead of
piling up on the right and bottom edges. Store centres, per-dot peak alpha
(MIN_PEAK..1) and a per-dot [0,1) scatter value in typed arrays, and group the
dot indices into one bucket per ink.
- Determinism: every per-dot value comes from a 32-bit LCG seeded by `seed`, in
a fixed traversal order. Math.random() is never called — not during render
(purity/SSR) and not in the loop. The same box always paints the same pattern.
- THE central idea: hovering does not fade a layer in, it SCHEDULES each dot.
On reveal, dot i starts at clock + distance(dot, origin) / WAVE_SPEED +
scatter[i] * IGNITE_SCATTER, so the light travels outward from the pointer at
a real speed (~1150 CSS px/s) and the front arrives ragged instead of as a
clean expanding ring. On release the schedule is scatter[i] * RELEASE_SCATTER
with NO distance term: a wave retreating to wherever the cursor happened to
exit reads as a mistake, while a scattered dissolve reads as the field letting
go. Each dot then eases toward 0 or 1 at a fixed rate (FADE_IN ~0.2s,
FADE_OUT ~0.36s), and alpha = level * peak * opacity.
- Origin: pointerenter records the crossing point as 0..1 of the box; focusin
records the focused control's centre the same way; anything else (the `active`
prop) uses the box centre. There is NO pointermove listener — the entry point
is the entire gesture, so a 240Hz pointer stream costs nothing.
- Interruption is free: re-entering during the dissolve just reschedules from
the dots' current levels, so the field never snaps or double-fades.
- The loop closes itself. Each frame returns whether any dot is still moving or
still waiting its turn; when the answer is no, the rAF chain simply is not
re-armed. A settled panel — lit or dark — holds zero frame slots, which is
what makes a grid of twelve of these viable.
- Power and correctness: an IntersectionObserver and visibilitychange do not
merely pause the loop, they SETTLE it — levels snap to the end state in one
paint, because nobody can watch a transition that is off screen or in a hidden
tab, and the panel must be correct the moment it comes back. dt is clamped to
1/30s so a resumed tab cannot teleport the sweep.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing.
devicePixelRatio capped at 2 sets the backing store, and ctx.setTransform is
re-applied after every resize because writing canvas.width resets the context.
A resize rebuilds the lattice from the seed and lands on the current state
rather than replaying a sweep under a moving wavefront.
- Ink: the palette reaches the canvas as inline custom properties
(--zy-dot-0..n = var(--<token>)); the loop reads their COMPUTED values, which
already have their var() references substituted, and assigns those strings
straight to fillStyle with alpha on globalAlpha. Never hand-parse a colour —
passing the computed string through means any syntax the browser understands
keeps working (oklch, color-mix, a brand colour parked behind --chart-1). The
canvas also carries a token text colour as the fallback ink for a palette
entry that does not resolve. A MutationObserver on <html> (class / style /
data-theme) re-reads the inks immediately and once more ~400ms later, because
a palette animated with transition-colors reports intermediate values for a
few hundred ms and a settled field would latch one forever.
- Pointer reality: gate the hover path on the event's own pointerType, NOT on
matchMedia("(pointer: coarse)") — that query describes the device's primary
pointer, so a mouse or trackpad attached to a tablet would lose the reveal
entirely. Skip it when event.pointerType === "touch" (a touch contact is the
start of a tap or a scroll, and a flash under a moving thumb is noise) and
reveal for mouse and pen. Focus still reveals in every case. Nothing captures
the pointer, calls preventDefault or sets touch-action, so scrolling over the
panel is never affected.
- prefers-reduced-motion: reduce — read it with matchMedia through
useSyncExternalStore (server snapshot false, so it is hydration-safe, and a
mid-session toggle is respected). Under reduce the reveal still HAPPENS: the
field lands on its end state in a single paint. Only the travel is dropped,
and the children were never hidden to begin with.
- Cleanup on unmount: cancelAnimationFrame, both observers, the
MutationObserver, the theme-settle timeout and the visibilitychange listener.
The matchMedia listeners are torn down by useSyncExternalStore's subscribe.
Rendering & styling
- Semantic tokens only, zero colour literals: var(--chart-1)..var(--chart-5) (or
any token name the consumer passes) for the dots, text-muted-foreground on the
canvas as the fallback ink. The panel's own surface is the consumer's
className (bg-card / border / rounded-2xl), merged through cn().
- Accessibility: the canvas is aria-hidden and pointer-events-none — the dots
are decoration and carry no information, so there is nothing to announce and
no live region. Children stay fully interactive above the field and are never
faded, masked or moved, so a panel with motion off, JS broken or a canvas
context refused is still a perfectly ordinary card. Everything that reacts to
hover reacts to focus as well, via focusin on any child; consumers put their
own focus-visible ring on that child.
Customization levers
- Density and mass: spacing is the pitch, dotSize the ink. 7px/2px is a fine
mesh that reads as snapping on; 26px/10px is a signboard. dotSize is clamped
to the pitch, so the two cannot fuse into a sheet.
- Palette: colors takes any token names — one for a monochrome brand field,
several for a spectrum. opacity is the legibility lever when small copy sits
on top; seed reshuffles the ignition order, the brightness texture and the ink
map without touching anything else.
- Motion: speed scales the sweep and the fades together. WAVE_SPEED (px/s) is
how fast the light travels, IGNITE_SCATTER / RELEASE_SCATTER how ragged the
front and the dissolve are, FADE_IN / FADE_OUT how snappy one dot is,
MIN_PEAK how much brightness variance the matrix has.
- Cost: MAX_DOTS (6000) is the safety valve — lower it for phones; MAX_DPR 1
halves the fill cost on retina.
- Trigger: leave `active` undefined for a plain hover/focus panel, drive it from
your own state for a "held" or step-by-step reveal, and use `disabled` to mute
the effect without unmounting anything.
- Structure: the children are yours entirely — a headline and a CTA, a whole
pricing column, a bento tile. Hang extra reactions off
group-data-[state=revealed]/reveal: instead of duplicating the hover state.Concepts
- Scheduled ignition, not a fade-in — hovering does not animate an opacity, it hands every dot a moment:
distance / wave speed + seeded scatter. That is why the light has a direction and a texture instead of reading as a layer that faded on. - The origin is the crossing point — the wave starts where the pointer actually entered the panel, and where the focused control sits when you Tab in. Same gesture, same reading, no separate keyboard behaviour to maintain.
- Scattered dissolve on release — the exit deliberately drops the distance term. A wave retreating to wherever the cursor happened to leave looks like a bug; a seeded scatter looks like the field letting go.
- Self-closing loop — each frame reports whether any dot is still moving or still waiting its turn, and the rAF chain is simply not re-armed when the answer is no. A grid of twelve settled panels costs zero frames.
- Settle, don't pause — off screen, in a hidden tab, or with motion turned off, the transition lands on its end state in a single paint. The reveal still happens; only its travel is dropped, which is also exactly what
prefers-reduced-motiongets. - Decoration that cannot hide content — the canvas is
aria-hiddenandpointer-events-none, and the children are ordinary DOM above it. No canvas, no JS, no motion, touch only: the panel is still a readable card, so there is nothing to announce and nothing to recover.
Magnetic Grid
A lattice of tiles that leans toward — or away from — the cursor, resolved for every tile from a single pointer listener with distance falloff.
Split Screen Hover
Full-height panels that share one row and rebalance around the pointer — the hovered or focused panel takes the width and opens its copy, the rest compress and dim.