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:noneso 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 theactiveclass. 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)
- Create
<div class="grid-overlay">. width = element.offsetWidth(700),height = element.offsetHeight(500).cols = Math.ceil(width / blockSize)→ceil(700/25)= 28;rows = Math.ceil(height / blockSize)→ceil(500/25)= 20. So 28 × 20 = 560 cells.- Loop
row0..rows-1,col0..cols-1. For each cell create<div class="grid-block">and: isEmpty = Math.random() < emptyRatio(30%).textContent = isEmpty ? "" : getRandomSymbol().- Absolutely position it:
style.width/height = "25px",style.left = col*25 + "px",style.top = row*25 + "px". - 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 } ``
- Append
.grid-overlayintoelement.
Pointer interaction (mousemove on .hover-img)
On every mousemove:
rect = element.getBoundingClientRect();mouseX = e.clientX - rect.left,mouseY = e.clientY - rect.top.- Find the single closest cell by scanning all
blocksand computing Euclidean distance from the mouse to each cell center(block.x, block.y); keep the minimum. - Guard: if there is no closest block or
closestDistance > detectionRadius(50px),return— nothing happens when the pointer is far from any cell center. currentTime = Date.now(). Activate the closest cell:closestBlock.element.classList.add("active").closestBlock.highlightEndTime = currentTime + blockLifetime(now + 300 ms).- If
closestBlock.shouldScramble && !closestBlock.scrambleInterval: startsetInterval(() => el.textContent = getRandomSymbol(), scrambleInterval)(change its glyph every 150 ms) and store the id. - Random neighbor chain (the trail spread):
clusterCount = Math.floor(Math.random() * clusterSize) + 1→ a random integer 1..7.currentBlock = closestBlock,activeBlocks = [closestBlock].- Loop
ifrom0toclusterCount-1: - Build
neighbors= allblocksnot already inactiveBlockswhose grid position is within the 8-cell Moore neighborhood ofcurrentBlock:Math.abs(gridX - currentBlock.gridX) <= 1 && Math.abs(gridY - currentBlock.gridY) <= 1. - If
neighborsis empty,break. - Pick a random neighbor. Add
active, set itshighlightEndTime = 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. - Push it to
activeBlocksand setcurrentBlock = randomNeighbor. - Because
currentBlockadvances to the just-picked neighbor, this is a random walk: eachmousemovelights 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↔1class 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
mousemoveover 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/offsetHeightat 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#e3e3dbmust match these values for the look to read correctly.