Aduok Code

Scroll Reveal Grid with Pure HTML, CSS & Vanilla JS

Introduction

In this article we build a Scroll Reveal Grid — a full-page scroll experience where a single hero image fills the entire viewport on load, then shrinks into its natural cell in a 5-column photo grid as the user scrolls. As the hero collapses, three concentric rings of images scale and fade in around it, each with its own easing curve, creating a layered reveal that feels physically grounded. The entire animation is driven by a plain scroll event listener, a hand-rolled cubic-bezier solver, and CSS custom properties. No libraries. No dependencies. Not a single external script.

The layout uses CSS subgrid to place images across an overlapping multi-layer grid without any absolute positioning hacks. The scroll engine converts raw scrollTop into a 0→1 progress value, slices it into per-element sub-ranges, passes each through a cubic-bezier easing function, and writes the result directly to CSS custom properties on each element — which CSS then consumes via opacity and scale.

What you will learn

How to use CSS subgrid to stack multiple layers of images on the same grid tracks — how to create the sticky-canvas scroll spacer trick that gives scroll-driven animations room to breathe — how to implement a Newton-Raphson cubic-bezier solver in vanilla JS so you can replicate any CSS timing curve precisely — how to map a single scroll progress value into staggered per-element sub-ranges — how to drive CSS opacity and scale from JS using CSS custom properties as a bridge — and how to handle reduced-motion preferences and touch devices without a media query library.

01
Part One
The Core Concept

Step 1 — How the Animation Works

The effect has two simultaneous animations that run over the same scroll window. The first is the hero shrink: the center image starts at viewport width × viewport height and transitions to its natural cell size as measured from the DOM. The second is the ring reveal: three groups of images — the outer edge pair, the inner column pair, and the center top-and-bottom pair — each scale from 0 to 1 and fade from 0 to 1 opacity, but with different easing curves and slightly different end points so the outer ring finishes last, creating a staggered wave.

Everything is driven from a single scroll handler. Each frame, the handler reads scrollTop from an internal scroll container div, converts it to a 0→1 progress value relative to the scroll section height, then calls a subProgress() helper to remap that global progress into a per-element range. For example, the hero animation runs over global progress 0→0.8 while the outermost ring runs over 0→1. Passing the sub-range output through a cubic bezier easing function gives each element its own acceleration curve.

Why an internal scroll container instead of the window?

The component is designed to be embedded in an artifact viewer or iframe with a fixed height. The window scroll event never fires inside an iframe — the document is smaller than the viewport, so there is nothing to scroll. Wrapping all content in a div with height: 100vh and overflow-y: scroll creates a self-contained scroll context. Passing { container: scrollerDiv } to any scroll-linked library — or simply reading scrollerDiv.scrollTop in a plain event listener — gives full access to scroll position inside the iframe.

02
Part Two
HTML Structure

Step 2 — The Markup

The component needs four layers of nesting. The scroll-root div is the internal scroll container — it has height: 100vh and overflow-y: scroll. Inside it, scroll-section is the tall spacer element: min-height: 240vh gives the scroll animation 140vh of travel. The sticky-canvas div inside it is position: sticky; top: 0 — it pins to the top of the viewport and stays there while the spacer scrolls past. Inside the sticky canvas lives the photo-grid with all the image rings and the hero cell.

HTML
<div class="scroll-root" id="scroll-root">
  <main>

    <!-- tall spacer — gives scroll room to breathe -->
    <section class="scroll-section" id="scroll-section">

      <!-- pins to top while the spacer scrolls -->
      <div class="sticky-canvas">

        <div class="photo-grid" id="photo-grid">

          <!-- ring 1: outermost — col 1 and col 5 -->
          <div class="col-ring"> ... 6 images ... </div>

          <!-- ring 2: inner — col 2 and col 4 -->
          <div class="col-ring"> ... 6 images ... </div>

          <!-- ring 3: center column — top and bottom rows only -->
          <div class="col-ring"> ... 2 images ... </div>

          <!-- hero: center cell — starts fullscreen, shrinks in -->
          <div class="hero-cell">
            <img id="hero-img" src="..." alt="Hero">
          </div>

        </div>
      </div>
    </section>

    <!-- section after the animation — visible once scroll completes -->
    <section class="outro-section">
      <h2 class="brand-title">explore the wild.</h2>
    </section>

  </main>
</div>
ElementRoleAnimated
#scroll-rootInternal scroll container — height 100vh, overflow-y scroll. Fires scroll events inside an iframeNo — scroll host only
#scroll-sectionTall spacer — min-height 240vh. The extra 140vh is the scroll budget for the animationNo — passive spacer
.sticky-canvasSticky viewport — position sticky; top 0. Stays pinned while the spacer scrolls past itNo — CSS sticky only
.photo-grid5-column × 3-row CSS grid. All rings and hero share the same grid tracks via subgridNo — layout container
.col-ring (×3)Subgrid layer spanning all columns and rows. Each ring places images in specific columns via nth-of-type rulesYes — scale and opacity driven by --ring-scale and --ring-opacity CSS vars
.hero-cell imgCenter image. Starts at viewport dimensions (set via inline style). JS shrinks it to natural cell size on scrollYes — width and height via inline style each frame
03
Part Three
The Subgrid Layout

Step 3 — Stacking Layers with CSS Subgrid

The photo grid uses a 5-column, 3-row CSS grid. Each col-ring div spans the full grid via grid-column: 1 / -1 and grid-row: 1 / -1, then declares grid-template-columns: subgrid and grid-template-rows: subgrid — this makes each ring inherit the parent grid tracks exactly, so images placed inside a ring land in the correct parent columns without any offset math. Three rings overlap the same grid area simultaneously, each placing its images in different columns.

CSS
.photo-grid {
  --offset: 0;   /* changes to -1 on mobile 3-col layout */
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-template-rows: repeat(3, auto);
  gap: clamp(8px, 4vw, 56px);
  position: absolute;
  top: 50%; left: 50%;
  translate: -50% -50%;
}

/* every ring spans the full grid area and inherits its tracks */
.photo-grid > .col-ring {
  display: grid;
  grid-column: 1 / -1;
  grid-row: 1 / -1;
  grid-template-columns: subgrid;
  grid-template-rows: subgrid;
}

/* ring 1: odd images → col 1, even images → col 5 (last) */
.photo-grid > .col-ring:nth-of-type(1) div:nth-of-type(odd)  { grid-column: 1; }
.photo-grid > .col-ring:nth-of-type(1) div:nth-of-type(even) { grid-column: -2; }

/* ring 2: odd → col 2, even → col 4 */
.photo-grid > .col-ring:nth-of-type(2) div:nth-of-type(odd)  { grid-column: calc(2 + var(--offset)); }
.photo-grid > .col-ring:nth-of-type(2) div:nth-of-type(even) { grid-column: calc(-3 - var(--offset)); }

/* ring 3: both images in center col — first in row 1, last in row 3 */
.photo-grid > .col-ring:nth-of-type(3) div:first-of-type { grid-column: calc(3 + var(--offset)); grid-row: 1; }
.photo-grid > .col-ring:nth-of-type(3) div:last-of-type  { grid-column: calc(3 + var(--offset)); grid-row: -1; }

/* hero occupies center cell: row 2, col 3 */
.hero-cell { grid-area: 2 / calc(3 + var(--offset)); }
Why subgrid instead of absolute positioning?

Without subgrid, each ring would need to be absolutely positioned and its children would need explicit grid-area or left/top values — any change to column count or gap would break the layout. With subgrid, each ring inherits the parent grid tracks automatically. Changing grid-template-columns on the parent instantly reflows all three rings and the hero with no child changes required. It also means the browser handles all alignment — no calculated offsets that drift at different viewport sizes.

04
Part Four
The Sticky Canvas

Step 4 — The Sticky Spacer Trick

The animation needs scroll distance to run over, but the visual content should not move — it should appear frozen while the user scrolls. This is achieved by wrapping the grid in a position: sticky; top: 0 element inside a much taller section. The section is min-height: 240vh so it occupies 240% of the viewport height in the document flow. The sticky canvas inside it sticks to the top of the viewport, appearing stationary. The scroll-section acts purely as a scroll spacer — it contributes height for scrolling but its sticky child is always in view.

The sticky canvas trick is the backbone of any scroll-driven animation: one tall parent provides the scroll budget, one sticky child stays on screen. The ratio between the child height (100vh) and the parent height (240vh) is exactly the number of viewport heights of scroll travel available for the animation — in this case, 140vh of room.

05
Part Five
DIY Cubic Bezier

Step 5 — Implementing a Cubic Bezier Solver

CSS transition uses cubic-bezier easing internally, but when driving animations from JS via scroll position there is no browser API to apply a cubic bezier to an arbitrary value. The solver must be implemented manually. A CSS cubic-bezier(x1, y1, x2, y2) curve is a parametric Bezier curve defined by two control points. Given an input progress t (0→1 along the time axis X), the goal is to find the output value (0→1 along the Y axis). The challenge is that t is a parameter of the curve, not the X axis directly — so finding the Y value for a given X requires inverting the X polynomial, which is done with Newton-Raphson iteration.

JS
function makeCubic(x1, y1, x2, y2) {
  return function ease(t) {
    if (t <= 0) return 0;
    if (t >= 1) return 1;

    // polynomial coefficients for X and Y
    const cx = 3 * x1,      bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
    const cy = 3 * y1,      by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;

    // sample the X polynomial at parameter st
    const sampleX  = st => ((ax * st + bx) * st + cx) * st;
    const sampleY  = st => ((ay * st + by) * st + cy) * st;
    const dSampleX = st => (3 * ax * st + 2 * bx) * st + cx;

    // Newton-Raphson: find st such that sampleX(st) === t
    let st = t;
    for (let i = 0; i < 8; i++) {
      const x = sampleX(st) - t;
      const d = dSampleX(st);
      if (Math.abs(d) < 1e-6) break;
      st -= x / d;
    }

    // Y value at the found parameter is the eased output
    return sampleY(st);
  };
}

// Replicate the four easing curves from the original design
const easeHeroW = makeCubic(0.65, 0, 0.35, 1);   // hero width  — smooth symmetric
const easeHeroH = makeCubic(0.42, 0, 0.58, 1);   // hero height — slightly snappier
const easeFade  = makeCubic(0.61, 1, 0.88, 1);   // ring fade   — overshoot then settle
const easeRings = [
  makeCubic(0.42, 0, 0.58, 1),   // ring 1 — power1
  makeCubic(0.76, 0, 0.24, 1),   // ring 2 — power3
  makeCubic(0.87, 0, 0.13, 1),   // ring 3 — power4
];
Why 8 iterations of Newton-Raphson?

Newton-Raphson converges quadratically — each iteration roughly doubles the number of correct decimal places. After 8 iterations the error is below 1e-15, which is below the precision of a 64-bit float. The early exit when the derivative is below 1e-6 handles the degenerate case where the curve is nearly vertical (infinite slope), which would cause division by near-zero. In practice, standard CSS easing curves never reach this condition, but the guard makes the solver robust for any input.

06
Part Six
Hero Shrink Animation

Step 6 — Shrinking the Hero from Fullscreen to Cell

The hero image starts at the full viewport size and must shrink to exactly its natural grid cell size by the end of the scroll animation. The natural size cannot be hardcoded — it depends on the viewport width, the number of grid columns, and the gap size, all of which are fluid. Instead, the dimensions are measured directly from the DOM: heroImg.offsetWidth and heroImg.offsetHeight after the layout has been computed. The hero image inline style is cleared before measuring so the browser calculates the natural cell size, then the initial fullscreen dimensions are applied back via inline style before the animation begins.

JS
function measure() {
  // viewport dimensions from the scroll container (not window — we're in an iframe)
  vw = scroller.clientWidth;
  vh = scroller.clientHeight;

  // temporarily clear inline size so browser computes natural grid cell size
  heroImg.style.width  = '';
  heroImg.style.height = '';

  // read natural dimensions after layout
  nw = heroImg.offsetWidth;
  nh = heroImg.offsetHeight;
}

function onScroll() {
  const sectionH   = section.offsetHeight;       // 240vh in pixels
  const scrollable = sectionH - vh;              // max scrollTop before section ends
  const raw        = Math.max(0, Math.min(scroller.scrollTop, scrollable));
  const p          = raw / scrollable;           // global progress: 0 → 1

  // hero runs over the first 80% of scroll progress
  const heroP = subProgress(p, 0, 0.8);

  heroImg.style.width  = lerp(vw, nw, easeHeroW(heroP)) + 'px';
  heroImg.style.height = lerp(vh, nh, easeHeroH(heroP)) + 'px';
}

// map global progress into a per-element sub-range → 0 to 1
function subProgress(p, start, end) {
  return Math.max(0, Math.min(1, (p - start) / (end - start)));
}

const lerp = (a, b, t) => a + (b - a) * t;

Measuring from the DOM instead of hardcoding guarantees the hero lands exactly on its grid cell at every viewport size. If the grid has a clamp() gap or a fluid column count, the measured nw and nh already account for all of that — the animation end point is always pixel-perfect.

07
Part Seven
Ring Scale & Fade

Step 7 — Staggered Ring Reveal via CSS Custom Properties

Each ring uses two CSS custom properties as a bridge between JS and CSS: --ring-scale controls the scale transform and --ring-opacity controls opacity. These are declared as variables on each .col-ring element and consumed in CSS. The JS scroll handler writes new values to them each frame via setProperty(). Because scale and opacity are declared in CSS using var() they remain subject to CSS will-change compositing hints — the browser can promote these elements to their own compositor layers, making the animation GPU-accelerated even though the values come from JS.

CSS
.col-ring {
  opacity: var(--ring-opacity, 0);   /* reads JS-written value; defaults to 0 */
  scale:   var(--ring-scale, 0);     /* same pattern for scale */
  will-change: opacity, transform;   /* promote to compositor layer */
}
JS
rings.forEach((ring, i) => {
  // each ring ends at a slightly different scroll progress: 1.0, 0.95, 0.90
  const endFrac = 1 - i * 0.05;
  const rp      = subProgress(p, 0, endFrac);   // ring's own 0→1 progress

  // opacity: hold at 0 until 55% of ring progress, then ease to 1
  const fadeP  = subProgress(rp, 0.55, 1);
  // scale: hold at 0 until 30% of ring progress, then ease to 1
  const scaleP = subProgress(rp, 0.30, 1);

  ring.style.setProperty('--ring-opacity', easeFade(fadeP));
  ring.style.setProperty('--ring-scale',   easeRings[i](scaleP));
});
Why hold at 0 before animating — the offset keyframe pattern

The opacity and scale each start their actual animation at different points into the ring's own progress window (55% and 30% respectively). This replicates the Motion library keyframe offset pattern [0, 0.55, 1] — value holds at its start state for the first portion of the timeline, then animates over the remainder. The hold creates a delay without using CSS animation-delay, which cannot be driven by scroll position. The subProgress() helper converts any global range into a clean 0→1 by clamping values outside the range to 0 or 1.

08
Part Eight
The Scroll Engine

Step 8 — Wiring the Scroll Engine

The complete scroll engine is a single event listener on the internal scroller div, plus a measure() call on first paint and a resize listener to re-measure when the viewport changes. requestAnimationFrame is used to defer the initial measure and first paint until after the browser has computed layout — reading offsetWidth or clientHeight before layout is complete returns 0, which would set the hero to 0×0 on load.

JS
const scroller = document.getElementById('scroll-root');
const section  = document.getElementById('scroll-section');
const heroImg  = document.getElementById('hero-img');
const rings    = document.querySelectorAll('.col-ring');

let vw, vh, nw, nh;  // measured on init and resize

requestAnimationFrame(() => {
  measure();      // read viewport + natural cell dimensions
  onScroll();     // apply initial state (progress = 0, hero fullscreen)

  scroller.addEventListener('scroll', onScroll, { passive: true });
  window.addEventListener('resize',  () => { measure(); onScroll(); });
});
passive: true on the scroll listener

Adding { passive: true } tells the browser that this scroll handler will never call preventDefault(). The browser can then move scroll handling entirely to the compositor thread — it no longer needs to wait for the JS to return before painting the next frame. This eliminates scroll jank on mobile. All scroll-linked animation listeners that do not block scrolling should be passive.

09
Part Nine
Responsive & Reduced Motion

Step 9 — Responsive Layout & Reduced Motion

On viewports below 600px the grid drops from 5 columns to 3. The outermost ring (col-ring:nth-of-type(1)) is hidden because its columns — 1 and 5 — do not exist in the 3-column layout. A CSS --offset variable on the grid shifts the inner ring and center column references by -1 so they align correctly in the narrower layout. The JS animation code requires no changes — it reads the natural cell size after the CSS layout has updated, so the hero shrink target automatically adjusts.

CSS
@media (max-width: 600px) {
  .photo-grid {
    grid-template-columns: repeat(3, 1fr);
    --offset: -1;   /* shifts ring 2 and ring 3 column references left by 1 */
  }
  .photo-grid > .col-ring:nth-of-type(1) { display: none; }
}

/* reduced motion: skip all animation — show final state immediately */
@media (prefers-reduced-motion: reduce) {
  .col-ring {
    opacity: 1 !important;
    scale:   1 !important;
  }
  .hero-cell img {
    width:  100% !important;
    height: 100% !important;
  }
}

The prefers-reduced-motion override uses !important to win over the inline styles written by JS. The JS itself also has an early return at the top — if reduced motion is detected, no event listeners are attached and no inline styles are ever written. The CSS override handles the case where JS has already run before the media query was checked, or where the user switches their OS preference mid-session.

Tuning Reference

Variable / ValueDefaultEffect
scroll-section min-height240vhTotal scroll budget. 240vh means 140vh of scroll travel for the animation. Increase for a slower reveal
hero scroll range0 → 0.8 of global progressHero shrinks over the first 80% of the scroll window. Decrease 0.8 to finish earlier
ring end fractions1.0, 0.95, 0.90Each ring finishes at a slightly earlier progress point. Increase the 0.05 step for a more staggered reveal
ring fade hold point55%Opacity stays at 0 until 55% of the ring's own progress. Lower to start fading earlier
ring scale hold point30%Scale stays at 0 until 30% of the ring's own progress. Lower to start growing earlier
easeHeroWcubicBezier(0.65, 0, 0.35, 1)Hero width easing — smooth symmetric deceleration. Replace with makeCubic(0.4, 0, 0.2, 1) for a sharper snap
easeHeroHcubicBezier(0.42, 0, 0.58, 1)Hero height easing — slightly different from width to give a non-uniform collapse feel
easeRings[0]cubicBezier(0.42, 0, 0.58, 1)Outer ring easing — gentle. Replace with makeCubic(0.87, 0, 0.13, 1) for a very sharp pop
easeRings[1]cubicBezier(0.76, 0, 0.24, 1)Middle ring easing — moderate snap
easeRings[2]cubicBezier(0.87, 0, 0.13, 1)Inner ring easing — sharpest curve, feels most physical
--gapclamp(8px, 4vw, 56px)Grid gap between cells. Increase for a more airy layout, decrease for a tighter grid
grid-template-columnsrepeat(5, 1fr)Column count. Change to repeat(7, 1fr) and add a 4th ring for a wider spread
mobile breakpoint600pxBelow this width the grid switches to 3 columns and the outer ring is hidden

Full Source Code

Save the following as scroll-reveal-grid.html and open in any modern browser. CSS subgrid requires Chrome 117+, Safari 16+, or Firefox 71+. The scroll event approach works in every browser that supports CSS Grid. The cubic bezier solver is pure JS with no browser API dependencies — it runs everywhere.

HTML — scroll-reveal-grid.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/scroll-reveal-grid.html

The entire scroll engine — the cubic bezier solver, the sub-range mapper, the hero shrink, the staggered ring reveal — is fewer than 60 lines of vanilla JS. No framework. No build step. No dependency graph to maintain. The CSS subgrid layout is another 40 lines. The most complex part of the component is the easing math, and that is just high-school calculus running in a tight loop.

Want to see it in action?Watch the full build on YouTube