Image Explosion Scroll Animation
Goal
Build a single scrollable page where, the moment the footer scrolls halfway into view, 15 images violently burst upward out of the footer's bottom edge and rain back down under gravity — a one-shot "image explosion" driven by a hand-rolled requestAnimationFrame physics simulation (per-particle velocity, gravity, friction and rotation). No GSAP is used for this effect; the physics loop IS the effect.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use lenis (npm) for smooth scrolling. No GSAP plugins are required — the explosion is pure JavaScript physics updating style.transform inside a requestAnimationFrame loop.
import Lenis from "lenis";
Wrap all JS in a DOMContentLoaded listener.
Layout / HTML
Four stacked blocks, in this order:
<section class="hero">— empty; full-screen background image.<section class="about">— contains one<p>of editorial copy (centered). Use this fictional text: "The world collapsed, but the game survived. In the neon-lit ruins of civilization, the last remnants of power aren't in governments or corporations—they're in the Oblivion Decks. Each card carries a fragment of lost history, a code of survival, a weapon of deception. The elite hoard them. The rebels steal them. The desperate gamble their lives for them. Do you have what it takes to play the game that decides the future?" (the**markers are literal text characters, not bold markup).<section class="outro">— empty; full-screen background image.<footer>— contains:<h1>The future is in your hands</h1><div class="copyright-info">with two<p>elements:© 2025 Oblivion DecksandAll rights reserved.<div class="explosion-container"></div>— empty; the JS injects the particle<img>elements here.
Styling
- Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. - All
<p>:text-transform: uppercase; font-family: "Akkurat Mono", monospace; font-size: 14px;. - All
<img>:width: 100%; height: 100%; object-fit: cover;. - Every
section:position: relative; width: 100vw; height: 100svh; padding: 2em;. .heroand.outro: full-bleed background image,no-repeat 50% 50%,background-size: cover(each with its own image)..about:color: #000; background-color: #e3e3db;flexbox centering both axes; itspiswidth: 50%; text-align: center;.footer:position: relative; width: 100vw; height: 75svh; background-color: #0f0f0f; color: #fff; padding: 2em;— column flexbox withjustify-content: space-between; align-items: center;and, critically,overflow: hidden(particles must be clipped by the footer).footer h1:text-transform: uppercase; font-family: "FK Screamer", sans-serif; font-size: 12vw; font-weight: 500; line-height: 0.85;(a very heavy condensed display sans; fall back to any ultra-condensed sans-serif)..copyright-info:width: 100%; display: flex; justify-content: space-between;..explosion-container:position: absolute; bottom: 0; left: 0; width: 100%; height: 200%; pointer-events: none;(twice the footer's height so particles have vertical room)..explosion-particle-img:position: absolute; bottom: -200px; left: 50%; width: 150px; height: auto; object-fit: cover; transform: translateX(-50%); will-change: transform;— all 15 particles start stacked at the same point, hidden 200px below the container's bottom edge, horizontally centered.
The effect (be precise — this is the star)
Smooth scroll
Instantiate Lenis with exactly { autoRaf: true, lerp: 0.5 }.
Config constants
const config = {
gravity: 0.25, // px/frame² added to vy every frame
friction: 0.99, // per-frame multiplier on vx, vy and rotationSpeed
imageSize: 150, // particle width in px
horizontalForce: 20, // max horizontal launch speed spread
verticalForce: 15, // base upward launch speed
rotationSpeed: 10, // max rotation speed spread (deg/frame)
resetDelay: 500, // ms before the explosion can re-arm
};
Particles
imageParticleCount = 15. Build an array of 15 image paths and preload each one by creatingnew Image()and assigningsrc(no need to attach to DOM).createParticles(): setexplosionContainer.innerHTML = "", then for each path create an<img>with classexplosion-particle-img, setsrc, set inlinestyle.width = config.imageSize + "px", and append to.explosion-container.
Physics — Particle class
Constructor (takes the element):
x = 0,y = 0(positions are relative offsets from the CSS resting spot).vx = (Math.random() - 0.5) * config.horizontalForce→ range −10..+10 px/frame.vy = -config.verticalForce - Math.random() * 10→ range −15..−25 px/frame (negative = upward).rotation = 0,rotationSpeed = (Math.random() - 0.5) * config.rotationSpeed→ range −5..+5 deg/frame.
update() — called once per rAF frame:
vy += config.gravity(gravity pulls down).vx *= config.friction; vy *= config.friction; rotationSpeed *= config.friction;x += vx; y += vy; rotation += rotationSpeed;- Write
element.style.transform = "translate(" + x + "px, " + y + "px) rotate(" + rotation + "deg)". Note this inline transform replaces the CSStranslateX(-50%)centering — that's intentional and matches the original look.
Net motion: each image launches upward at 15–25 px/frame with a random horizontal drift and random spin, decelerates (gravity + friction), arcs over, and falls back down out through the footer's bottom edge.
Trigger + loop — explode()
- Guard with a module-level
explosionTriggeredboolean: if alreadytrue, return; else set ittrue. - Call
createParticles()(fresh DOM nodes each explosion), query all.explosion-particle-img, map each to anew Particle(el). - Start a rAF loop: every frame, update all particles; keep looping. When every particle's
y > explosionContainer.offsetHeight / 2(i.e. all have fallen well below their start point — container is 200% of footer height, so half of it is one full footer height),cancelAnimationFramethe loop and, afterconfig.resetDelay(500 ms), setexplosionTriggered = falseso the effect can fire again.
Scroll detection
checkFooterPosition(): readfooter.getBoundingClientRect(); if!explosionTriggered && footerRect.top <= window.innerHeight - footerRect.height * 0.5, callexplode(). (Fires once the footer is at least half revealed at the bottom of the viewport.)- Listen to
window"scroll"and debounce withclearTimeout+setTimeout(checkFooterPosition, 10). - On
window"resize", setexplosionTriggered = false(re-arm). - On init: call
createParticles()once (pre-populates the hidden stack), thensetTimeout(checkFooterPosition, 500)in case the page loads already scrolled to the footer.
Assets / images
17 images total, a cohesive stylized automotive/editorial photo set (no logos or brand marks):
- Hero background — 1 full-bleed landscape image, ~3:2 (e.g. a dramatic studio 3D render / concept-car style shot).
- Outro background — 1 different full-bleed landscape image, ~3:2, same moody editorial style.
- 15 particle images (
img1…img15) — mixed aspect ratios: mostly 16:9 landscape shots with a few portrait ones (~2:3 / ~5:6) sprinkled in. Rendered at 150px wide with natural height, so exact ratios are flexible; visually they should read as a varied editorial series (night roads, studio renders, desert scenes, close-up details, etc.).
Behavior notes
- The explosion is one-shot per approach but re-arms itself: after all particles fall past the halfway line of the container and the 500 ms delay elapses, scrolling away and back re-triggers it. Resizing also re-arms it.
pointer-events: noneon the container keeps the falling images from blocking clicks.overflow: hiddenon the footer is essential — particles must appear to erupt from and disappear behind the footer's bottom edge, never spill over other sections.- Works on mobile; no WebGL/canvas, just DOM images and transforms.