All components

Fiddle Digital Scramble Hover Effect

JavaScript animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A centered hero image is covered by an invisible grid of 25px blocks, some holding random symbols (O X * > $ W). Moving the mouse over the image activates the nearest block plus a random cluster of up to 7 neighbors (cobalt background, cream glyph) for ~300ms, while a subset of blocks scrambles its symbol every 150ms, leaving a digital-noise trail behind the cursor. Framed as a risograph print studio's catalogue plate.

How it's built

Categoryhover
Techvanilla JS
Complexitywidget
Performance costlight
Mobile-safedesktop-first

scramble pixel-grid hover image symbols vanilla-js cursor-trail

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Digital Scramble Hover Effect

Goal

Build a full-viewport page with a single centered hero image. The star effect: an invisible grid of tiny fixed cells is overlaid exactly on top of the image, and each cell secretly holds a random monospace symbol (O X * > $ W). As the cursor moves across the image, the cell nearest the pointer — plus a short random chain of up to 7 of its neighbors — briefly flips to a solid black tile with a visible white glyph, and a subset of those tiles scrambles its symbol every 150 ms. Each activated tile stays lit for ~300 ms then fades back to invisible. The net result is a live digital-noise / glitch trail of black glyph-blocks that follows the mouse across the picture, like the image is being "read" pixel-by-pixel.

Tech

Vanilla HTML/CSS/JS with an ES-module script (<script type="module" src="./script.js">). No GSAP, no Lenis, no libraries, no npm dependencies at all. The entire effect is hand-rolled with a mousemove listener, Date.now() timestamps, setInterval scramble tickers, and one requestAnimationFrame loop that expires lit cells. Do not reach for any tween/animation library — the original is intentionally timer- and class-toggle-driven (the only "animation" is a CSS opacity on/off).

Layout / HTML

Minimal, three fixed elements plus the hero. The grid overlay is built in JS, not authored in HTML.

<nav>
  <p>Scramble Hover Effect</p>
  <p>MP01701202025</p>
</nav>

<section class="hero">
  <div class="hover-img">
    <img src="/path/to/hero.jpg" alt="" />
  </div>
</section>

<footer>
  <p>Experiment 515</p>
  <p>Developed by Studio</p>
</footer>

<script type="module" src="./script.js"></script>

Use neutral placeholder text as above (no real brand names). The .hover-img wrapper is the interaction surface and the container the JS injects .grid-overlay into.

Styling

Fonts (Google Fonts): IBM Plex Mono (weights 100–700, used for the grid glyphs) and Inter (used for the nav/footer text).

  • Reset: * { margin:0; padding:0; box-sizing:border-box; }
  • body { background-color:#e3e3db; } — warm off-white/grey. This exact value is reused as the glyph text color so the white symbols match the page.
  • p { font-family:"Inter",sans-serif; font-size:0.8rem; font-weight:500; letter-spacing:-0.01rem; }
  • nav, footer { position:fixed; width:100%; padding:2rem; display:flex; justify-content:space-between; align-items:center; z-index:2; }nav { top:0; }, footer { bottom:0; }.
  • .hero { position:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; } — vertically & horizontally centers the image.
  • .hover-img { position:relative; width:700px; height:500px; overflow:hidden; }fixed 700×500 box (7:5). The fixed pixel size matters: it determines the grid dimensions (see below).
  • .hover-img img { width:100%; height:100%; object-fit:cover; }

Grid overlay styling (the JS injects these elements; the CSS must exist):

  • .grid-overlay { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:2; } — sits on top of the image; pointer-events:none so the mouse events fire on .hover-img, not the overlay.
  • .grid-block { position:absolute; display:flex; justify-content:center; align-items:center; background-color:#1a1a1a; color:#e3e3db; font-family:"IBM Plex Mono",monospace; font-size:20px; font-weight:400; opacity:0; } — near-black tile, off-white glyph, hidden by default (opacity:0).
  • .grid-block.active { opacity:1; } — the only thing that makes a tile visible is toggling the active class. There is no CSS transition, so the on/off is instant (the "fade" feel comes purely from how many tiles are lit at once around the cursor).

The effect (be exact — this is the whole component)

Config (top of the module)
const config = {
  symbols: ["O", "X", "*", ">", "$", "W"], // the 6 glyphs a cell can show
  blockSize: 25,        // px — cell width & height
  detectionRadius: 50,  // px — max distance mouse→cell-center to trigger
  clusterSize: 7,       // max length of the random neighbor chain
  blockLifetime: 300,   // ms a lit cell stays active
  emptyRatio: 0.3,      // 30% of cells are permanently blank (no glyph)
  scrambleRatio: 0.25,  // 25% of non-empty cells are "scramblers"
  scrambleInterval: 150 // ms between symbol changes while scrambling
};
const getRandomSymbol = () => config.symbols[Math.floor(Math.random() * config.symbols.length)];
Building the grid (initGridOverlay(element), run per .hover-img on DOMContentLoaded)
  1. Create <div class="grid-overlay">.
  2. width = element.offsetWidth (700), height = element.offsetHeight (500).
  3. cols = Math.ceil(width / blockSize)ceil(700/25) = 28; rows = Math.ceil(height / blockSize)ceil(500/25) = 20. So 28 × 20 = 560 cells.
  4. Loop row 0..rows-1, col 0..cols-1. For each cell create <div class="grid-block"> and:
  5. isEmpty = Math.random() < emptyRatio (30%). textContent = isEmpty ? "" : getRandomSymbol().
  6. Absolutely position it: style.width/height = "25px", style.left = col*25 + "px", style.top = row*25 + "px".
  7. Append to the overlay, and push a state object into a blocks[] array:

``js { element, x: col*blockSize + blockSize/2, // cell CENTER x (used for distance) y: row*blockSize + blockSize/2, // cell CENTER y gridX: col, gridY: row, highlightEndTime: 0, // timestamp when this cell should turn off isEmpty, shouldScramble: !isEmpty && Math.random() < scrambleRatio, // ~25% of filled cells scrambleInterval: null // holds the setInterval id while scrambling } ``

  1. Append .grid-overlay into element.
Pointer interaction (mousemove on .hover-img)

On every mousemove:

  1. rect = element.getBoundingClientRect(); mouseX = e.clientX - rect.left, mouseY = e.clientY - rect.top.
  2. Find the single closest cell by scanning all blocks and computing Euclidean distance from the mouse to each cell center (block.x, block.y); keep the minimum.
  3. Guard: if there is no closest block or closestDistance > detectionRadius (50px), return — nothing happens when the pointer is far from any cell center.
  4. currentTime = Date.now(). Activate the closest cell:
  5. closestBlock.element.classList.add("active").
  6. closestBlock.highlightEndTime = currentTime + blockLifetime (now + 300 ms).
  7. If closestBlock.shouldScramble && !closestBlock.scrambleInterval: start setInterval(() => el.textContent = getRandomSymbol(), scrambleInterval) (change its glyph every 150 ms) and store the id.
  8. Random neighbor chain (the trail spread):
  9. clusterCount = Math.floor(Math.random() * clusterSize) + 1 → a random integer 1..7.
  10. currentBlock = closestBlock, activeBlocks = [closestBlock].
  11. Loop i from 0 to clusterCount-1:
  12. Build neighbors = all blocks not already in activeBlocks whose grid position is within the 8-cell Moore neighborhood of currentBlock: Math.abs(gridX - currentBlock.gridX) <= 1 && Math.abs(gridY - currentBlock.gridY) <= 1.
  13. If neighbors is empty, break.
  14. Pick a random neighbor. Add active, set its highlightEndTime = currentTime + blockLifetime + i*10 (each successive link lives ~10 ms longer, so the tail fades slightly after the head). If it's a scrambler and not already scrambling, start its scramble interval too.
  15. Push it to activeBlocks and set currentBlock = randomNeighbor.
  16. Because currentBlock advances to the just-picked neighbor, this is a random walk: each mousemove lights the nearest cell then wanders 1–7 steps outward through touching cells, producing an irregular organic blob/trail rather than a neat radius.
The rAF expiry loop (updateHighlights)

One requestAnimationFrame loop runs forever:

function updateHighlights() {
  const currentTime = Date.now();
  blocks.forEach((block) => {
    if (block.highlightEndTime > 0 && currentTime > block.highlightEndTime) {
      block.element.classList.remove("active");   // turn the tile invisible again
      block.highlightEndTime = 0;
      if (block.scrambleInterval) {
        clearInterval(block.scrambleInterval);
        block.scrambleInterval = null;
        if (!block.isEmpty) block.element.textContent = getRandomSymbol(); // reset to a fresh symbol
      }
    }
  });
  requestAnimationFrame(updateHighlights);
}
updateHighlights();

So each lit cell auto-deactivates once Date.now() passes its highlightEndTime (~300 ms after it was touched), its scramble ticker is cleared, and a scrambler cell is reset to a new static symbol. The cursor continually re-lights cells ahead of it while the loop extinguishes the ones behind → a self-erasing trail.

Timing / feel summary
  • No easing, no interpolation, no transitions. Visibility is a hard opacity 0↔1 class toggle; the softness of the trail comes from many cells lighting/expiring around the moving pointer.
  • Head cells live 300 ms; chained tail cells 300–360 ms. ~25% of touched non-empty cells flicker through the 6 symbols at 150 ms while lit. ~30% of cells are permanently blank, so the trail has gaps (reads as "digital noise", not a solid block).
  • Triggered only by mousemove over the image; there is no load, scroll, or click animation.

Assets / images

One hero image, sized to fill a 700×500 (7:5) landscape box (object-fit:cover; the source asset is a square ~1:1 portrait that gets center-cropped to the landscape box). Role: the central subject the scramble grid overlays and appears to "scan". A stylized, graphic, high-contrast editorial illustration works best. The original is a hand-drawn, ink-and-wash / screenprint-style bust portrait of a young Black woman looking at the camera: she wears a white bucket hat, small gold hoop earrings, and a dark collared jacket over a white top. The palette is a striking two-tone duotone — her skin and clothing rendered almost entirely in bold cobalt/royal blue with near-black shadow linework, set against a flat warm cream/beige background; the hat and inner top read as off-white highlights. Any bold, poster-like portrait at ~7:5 with a single dominant accent color and a plain contrasting background is fine; do not use real brand imagery. Because the black glyph-tiles sit on top of it, an image with strong shapes and high color contrast reads well through the noise.

Behavior notes

  • Desktop / pointer-driven only — the whole effect is mousemove-based (no touch/click/scroll path in the original).
  • The grid is regenerated from offsetWidth/offsetHeight at init, so it assumes the fixed 700×500 box; keep that size (or the cell count changes).
  • Symbols, empty cells, and scrambler cells are randomized per page load, so the exact noise pattern differs each run — that's expected.
  • No reduced-motion branch; the rAF loop always runs. The page background #e3e3db, tile background #1a1a1a, and glyph color #e3e3db must match these values for the look to read correctly.