Anatomy of Encrypted Card
How a radial mask and a re-scrambling glyph stream turn a static card into a live ciphertext reveal that follows the pointer.
↓Scroll to step through it
Stream under, content over
EncryptedCard doesn't animate ciphertext across the surface — it keeps a
full glyph field painted underneath the content and reveals it through a soft
radial mask centered on the pointer. Two layers, one CSS variable pair, and
an interval that only runs while you hover.
The root is a group relative overflow-hidden card. An absolute, aria-hidden
glyph layer fills the inset with STREAM_LEN (1500) characters drawn from
DEFAULT_CHARS. Children sit in a sibling at z-raised, so real content
never fights the stream for stacking order.
The stream is filled client-side on mount so SSR markup stays deterministic —
randomString never runs during the first server render.
The reveal is a mask, not a clip of content
Pointer position never moves the glyphs. onPointerMove writes --x and
--y as pixel offsets from the card's bounding rect; the stream's
mask-image is a radial gradient whose center reads those variables (and
--reveal for radius). Defaults are 50% / 50%, so the first paint isn't
a corner flash.
Opacity is separate from the mask: the stream starts at opacity-0 and fades
to --stream-opacity on group-hover over 300ms. The mask only decides
where the faded-in stream is visible.
Scramble only while hovered
While hovered is true and reduced motion is off, a setInterval re-rolls
the entire 1500-character string every speed ms (default 55). Leaving the
card clears the interval; entering starts it again. That's why the ciphertext
looks alive under the spotlight without a forever-running timer in the
background.
The result
Hover the card and move across it — the spotlight and the scramble are the same two mechanisms from above.
One glyph field, one radial mask on --x/--y, and an interval that only
lives for the length of the hover.
- Glyph stream
- absolute inset-0 · STREAM_LEN 1500 · aria-hidden
- Content
- relative z-raised — children always on top
<div className="group relative overflow-hidden …"><div aria-hidden className={STREAM_BASE} style={{ color: streamColor }}> {stream}</div><div className="relative z-raised">{children}</div></div>Anatomy of Encrypted Card
How a radial mask and a re-scrambling glyph stream turn a static card into a live ciphertext reveal that follows the pointer.
- Glyph stream
- absolute inset-0 · STREAM_LEN 1500 · aria-hidden
- Content
- relative z-raised — children always on top
<div className="group relative overflow-hidden …"><div aria-hidden className={STREAM_BASE} style={{ color: streamColor }}> {stream}</div><div className="relative z-raised">{children}</div></div>Stream under, content over
EncryptedCard doesn't animate ciphertext across the surface — it keeps a
full glyph field painted underneath the content and reveals it through a soft
radial mask centered on the pointer. Two layers, one CSS variable pair, and
an interval that only runs while you hover.
The root is a group relative overflow-hidden card. An absolute, aria-hidden
glyph layer fills the inset with STREAM_LEN (1500) characters drawn from
DEFAULT_CHARS. Children sit in a sibling at z-raised, so real content
never fights the stream for stacking order.
The stream is filled client-side on mount so SSR markup stays deterministic —
randomString never runs during the first server render.
mask-image: radial-gradient(var(--reveal) circle at var(--x) var(--y), #000, transparent)
- Stream (full)
- always painted · opacity gated by group-hover
- Radial mask
- #000 inside → transparent outside · reveals, doesn't hide
el.style.setProperty("--x", `${e.clientX - rect.left}px`);el.style.setProperty("--y", `${e.clientY - rect.top}px`);// STREAM_BASE includes:// [mask-image:radial-gradient(var(--reveal) circle at var(--x,50%) var(--y,50%),#000_0%,transparent_100%)]The reveal is a mask, not a clip of content
Pointer position never moves the glyphs. onPointerMove writes --x and
--y as pixel offsets from the card's bounding rect; the stream's
mask-image is a radial gradient whose center reads those variables (and
--reveal for radius). Defaults are 50% / 50%, so the first paint isn't
a corner flash.
Opacity is separate from the mask: the stream starts at opacity-0 and fades
to --stream-opacity on group-hover over 300ms. The mask only decides
where the faded-in stream is visible.
setInterval(() => setStream(randomString(1500, chars)), speed)
- Opacity in
- 300ms ease · group-hover:[opacity:var(--stream-opacity)]
- Re-randomize
- setInterval(speed) · default 55ms · glyphs change, opacity steady
React.useEffect(() => {setStream(randomString(STREAM_LEN, characters));if (reduced || !hovered) return;const id = window.setInterval(() => { setStream(randomString(STREAM_LEN, characters));}, speed);return () => window.clearInterval(id);}, [characters, speed, hovered, reduced]);Scramble only while hovered
While hovered is true and reduced motion is off, a setInterval re-rolls
the entire 1500-character string every speed ms (default 55). Leaving the
card clears the interval; entering starts it again. That's why the ciphertext
looks alive under the spotlight without a forever-running timer in the
background.
Production API key
rotated 3 days ago
sk_live_51Nc••••••••4Rf2The result
Hover the card and move across it — the spotlight and the scramble are the same two mechanisms from above.
One glyph field, one radial mask on --x/--y, and an interval that only
lives for the length of the hover.
Reduced motion
Under prefers-reduced-motion, the stream still paints and the mask still
tracks — you can see the ciphertext — but the interval never starts, and
motion-reduce:[transition:none] kills the 300ms opacity fade. Reveal
without the tick.
const reduced = usePrefersReducedMotion();
// …interval skipped when reduced || !hovered
// STREAM_BASE: motion-reduce:[transition:none]Motion Score
opacityFade / cross-fade