Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Theme token names (without the leading `--`) the dots are painted with. */
const DEFAULT_COLORS = ["chart-1", "chart-2", "chart-3"]
/** More inks than this and the matrix reads as confetti; extras are dropped. */
const MAX_COLORS = 8
/**
 * Wavefront speed in CSS px per simulated second: how fast ignition travels
 * away from the pointer. A 560px panel entered at one edge lights up in ~0.5s.
 */
const WAVE_SPEED = 1150

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/canvas-reveal-hover.json

Prompt

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-motion gets.
  • Decoration that cannot hide content — the canvas is aria-hidden and pointer-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.

On This Page