Pixelated Block Reveal Scroll
Goal
Build a scroll-driven, full-bleed page where two editorial photo sections are each masked by a chunky grid of solid-color squares ("pixels"). As each photo section scrolls up through the viewport, the grid at its top edge dissolves block-by-block in a randomized wave to reveal the photo, and as the section scrolls away, a second grid at its bottom edge builds up block-by-block to cover the photo with the next section's color. The block colors are chosen so the transitions are seamless between the solid-color divider sections and the photos — a pixelated dissolve/build page transition, entirely scrubbed by scroll.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin only — no Lenis, no other plugins, no framework.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
Everything runs inside a DOMContentLoaded listener; call gsap.registerPlugin(ScrollTrigger) first. There are no tweens and no timeline — the whole effect is a set of ScrollTrigger.create() instances whose onUpdate writes each block's opacity imperatively from self.progress. Native scroll (no smooth-scroll library).
Layout / HTML
A single .container wrapping five stacked sections in this exact order:
<section class="hero">— a solid-color divider with a centered<h1>Section 1</h1>.<section class="hero-img">— the first photo reveal section (see block markup below), holdingimg-1.<section class="about">— a solid-color divider, centered<h1>Section 2</h1>.<section class="about-img">— the second photo reveal section, holdingimg-2.<section class="footer">— a solid-color divider, centered<h1>Section 3</h1>.
Each of the two *-img sections contains, in this order:
<img src="(photo)" alt="" />— the full-bleed background photo.<div class="blocks-container top">with exactly 4 empty<div class="blocks-row"></div>children.<div class="blocks-container bottom">with exactly 4 empty<div class="blocks-row"></div>children.
Example for one photo section:
<section class="hero-img">
<img src="/img-1.jpg" alt="" />
<div class="blocks-container top">
<div class="blocks-row"></div>
<div class="blocks-row"></div>
<div class="blocks-row"></div>
<div class="blocks-row"></div>
</div>
<div class="blocks-container bottom">
<div class="blocks-row"></div>
<div class="blocks-row"></div>
<div class="blocks-row"></div>
<div class="blocks-row"></div>
</div>
</section>
The .block squares themselves are not in the HTML — JS injects them into each .blocks-row. Load script.js with <script type="module" src="./script.js"></script> at the end of <body>. Class names .blocks-container, .blocks-row, .block, .top, .bottom are what the JS/CSS query — keep them exact.
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; }.
Palette (exact hex — the block colors depend on these):
.herobackground#757637(dark olive/khaki).aboutbackground#375955(dark teal-green).footerbackground#645439(warm brown)
Sections: section { position:relative; width:100%; min-height:100vh; }. The two photo sections override height: section.hero-img, section.about-img { position:relative; height:200vh; } — each photo section is two viewport-heights tall so there's a long stretch where the full photo shows, with the block grids only occupying the extreme top/bottom 400px.
Photos: img { position:absolute; width:100%; height:100%; object-fit:cover; z-index:-1; } — each fills its section and sits behind the blocks (blocks paint over it).
Headings: h1 { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:12vw; font-weight:600; letter-spacing:-0.025em; color:#000; opacity:0.5; }. Use a clean modern grotesque/neo-grotesque sans-serif (the original references a trial font "Saans TRIAL"; any similar tight-tracked sans works — set a sans-serif fallback, no external webfont required). The half-opacity black text reads as a subtle darkening over each solid divider.
Block grids:
.blocks-container { position:absolute; width:100%; height:400px; }..blocks-container.top { top:0; },.blocks-container.bottom { bottom:0; }— one grid pinned to the top edge of the photo section, one to the bottom edge..blocks-row { width:100%; height:100px; overflow:hidden; display:flex; }— 4 rows × 100px = the 400px container height;overflow:hiddenclips whatever blocks overflow the viewport width..block { width:100px; height:100px; transition:opacity 250ms; }— fixed 100px squares (the chunky "pixel" size, independent of viewport). Thetransition:opacity 250mssoftly smooths each per-frame opacity write into a gentle fade rather than a hard snap.- Initial opacities: top-grid blocks start opaque, bottom-grid blocks start transparent:
.blocks-container.top .block { opacity:1; }, .blocks-container.bottom .block { opacity:0; }.
Block colors — this is what makes the transition seamless. Each grid is painted the color of the neighbouring divider section, so a fully-opaque grid is indistinguishable from the adjacent solid section:
.hero-img .blocks-container.top .block { background:#757637; }(matches.heroabove).hero-img .blocks-container.bottom .block { background:#375955; }(matches.aboutbelow).about-img .blocks-container.top .block { background:#375955; }(matches.aboutabove).about-img .blocks-container.bottom .block { background:#645439; }(matches.footerbelow)
So scrolling top→bottom the color flow is: olive divider → olive top-grid dissolves to reveal photo 1 → (full photo 1) → teal bottom-grid builds up → teal divider → teal top-grid dissolves to reveal photo 2 → (full photo 2) → brown bottom-grid builds → brown divider. Continuous color the whole way.
GSAP effect (the important part — be exhaustive)
Everything is inside DOMContentLoaded, after gsap.registerPlugin(ScrollTrigger).
1. Inject the blocks
For every .blocks-row in the document, append exactly 16 <div class="block"> children:
document.querySelectorAll(".blocks-row").forEach((row) => {
for (let i = 0; i < 16; i++) {
const block = document.createElement("div");
block.className = "block";
row.appendChild(block);
}
});
So each row is 16 squares (1600px of blocks; the row is 100% wide with overflow:hidden, so only as many as fit the viewport are visible). Each grid is therefore a 4-row × up-to-16-column field of 100px squares.
2. One ScrollTrigger per row
Iterate every .blocks-container (there are 4 total: top+bottom on each of the 2 photo sections). For each container capture numRows = 4 and whether it is a top grid (isTop = container.classList.contains("top")). Then for each row (rowIndex 0→3, top row = 0):
blocks = Array.from(row.querySelectorAll(".block"))(16 elements).randomizedOrder = gsap.utils.shuffle(blocks.map((block, idx) => idx))— a shuffled permutation of the indices0..15. This is computed once per row at setup and fixed for the life of the page; it's what makes each row dissolve in a random per-block order.- Create a scrubbed ScrollTrigger on the container (all 4 rows of a container share the same trigger element and range):
ScrollTrigger.create({
trigger: container,
start: "top bottom", // progress 0 when the container's top edge reaches the viewport bottom
end: "bottom top", // progress 1 when the container's bottom edge reaches the viewport top
scrub: true, // plain scrub:true (directly tied to scroll, no smoothing lag)
onUpdate: (self) => {
const progress = self.progress; // 0 → 1 over (100vh + 400px) of scroll
const rowDelay = 0.25 * (numRows - rowIndex - 1); // 0.75, 0.5, 0.25, 0 for rows 0..3
const adjustedProgress = Math.max(0, Math.min(1, progress - rowDelay));
updateBlocksOpacity(blocks, randomizedOrder, isTop, adjustedProgress);
},
});
Row-stagger detail: rowDelay = 0.25 * (numRows - rowIndex - 1) gives the bottom row (index 3) a delay of 0 (it animates first, from progress 0) and the top row (index 0) a delay of 0.75 (it only begins at progress 0.75). Each row's local progress is clamp(progress − rowDelay, 0, 1). Net effect: the dissolve/build wave sweeps from the bottom row upward, one row starting each 0.25 of scroll progress.
3. updateBlocksOpacity(blocks, order, isTop, progress) — per-block math
For each block at DOM index idx:
const offset = order.indexOf(idx) / blocks.length; // this block's slot in the shuffle, normalized 0 → 15/16
const adjustedProgress = (progress - offset) * blocks.length; // ramps 0→1 across a 1/16-wide progress window
const opacity = isTop
? 1 - Math.min(1, Math.max(0, adjustedProgress)) // TOP grid: 1 → 0 (dissolve away, reveal photo)
: Math.min(1, Math.max(0, adjustedProgress)); // BOTTOM grid: 0 → 1 (build up, cover photo)
block.style.opacity = opacity;
Reading this out:
- Each block has an
offset= its position in the row's shuffled order, normalized to0 … 15/16. The block's opacity transition starts when the row's local progress reachesoffsetand completes1/16of progress later. So the 16 blocks of a row flip one after another, in the shuffled (random-looking) order, spread evenly across the row's whole local-progress range. - Top grid (
isToptrue): opacity goes 1 → 0 — the opaque colored blocks vanish one by one, revealing the photo underneath in a scattered, pixel-dissolve pattern. - Bottom grid (
isTopfalse): opacity goes 0 → 1 — transparent cells fill in one by one with the next section's color, pixel-building a cover over the photo.
There are no eases, durations, delays, or staggers in GSAP tweens here at all — every opacity is a direct clamped-linear function of scroll progress. The only softening is the CSS transition: opacity 250ms on .block and the scrub:true scroll binding.
4. Console note
The original logs each block's opacity every frame (console.log(...)). Omit that log — it spams the console with no visual effect; the reveal is identical without it.
Trigger geometry recap
- 2 photo sections × 2 grids × 4 rows = 16 ScrollTriggers, each
scrub:true,start:"top bottom",end:"bottom top"on its 400px container. - Because a grid is only 400px and sits at the extreme edge of a 200vh section, its whole animation happens as that edge enters/leaves the viewport; the middle of the 200vh section shows the bare photo with no grid.
Assets / images
- 2 full-bleed editorial fashion/portrait photos (JPG), displayed
object-fit:coverfilling a very tall (200vh) section, so effectively a portrait crop. Warm, editorial, softly-lit studio look that harmonizes with the olive / teal / brown palette: - Photo 1 — a sunlit editorial portrait of a person in a warm mustard-toned top against a beige backdrop (harmonizes with the olive grid).
- Photo 2 — a full-body editorial fashion portrait of a person in a navy/dark outfit against a draped beige-fabric backdrop with dramatic shadows (harmonizes with the teal→brown grids).
- Any two moody, warm-neutral editorial photos work; the point is that they read as premium fashion imagery revealed from behind the colored pixels. No brand names or logos.
Behavior notes
- Everything is fully reversible — scroll back up and the grids re-cover / re-reveal because every opacity is derived live from scroll progress.
- The shuffle order is generated once at load, so each row keeps a stable (but random-looking) dissolve pattern for the session; reloading reshuffles.
- No pin, no
pinSpacing, no smooth-scroll, no SplitText, no CustomEase, no Three.js, no reduced-motion branch in the original. - Works on mobile (
mobileSafe): fixed 100px blocks just show fewer columns on narrow viewports; the effect is light-weight (opacity writes only). - The 100px block size and 4-row/16-column counts are fixed values (not responsive); keep them as-is to match the chunky pixel scale.