Image Explosion Scroll Animation
Goal
Build a 4-section scroll page whose payoff is the footer: when the footer scrolls at least half-way into view, 15 photos erupt from below the footer's bottom edge like confetti, launched upward with randomized velocity and spin, then arc back down under simulated gravity until they fall out of sight. The whole effect is a hand-rolled particle physics system (velocity + gravity + friction + rotation) driven by requestAnimationFrame — no GSAP, no libraries, zero dependencies.
Tech
Vanilla HTML/CSS/JS. script.js is loaded as an ES module (<script type="module" src="./script.js">) but needs no imports whatsoever — do not install or import GSAP, Lenis or anything else. Everything is plain DOM + rAF.
Layout / HTML
Four blocks in <body>, in this order:
<section class="hero"></section>
<section class="about">
<p>
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**?
</p>
</section>
<section class="outro"></section>
<footer>
<h1>The future is in your hands</h1>
<div class="copyright-info">
<p>© 2025 Oblivion Decks</p>
<p>All rights reserved.</p>
</div>
<div class="explosion-container"></div>
</footer>
<script type="module" src="./script.js"></script>
Notes:
- The
double asterisksin the paragraph are literal text characters (raw markdown-style emphasis left unrendered) — keep them. .heroand.outroare empty; their imagery comes from CSSbackground..explosion-containerstarts empty; JS injects the particle<img>elements into it.
Styling
- Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. p { text-transform: uppercase; font-family: "Akkurat Mono", monospace; font-size: 14px; }— a monospaced family; the genericmonospacefallback is fine.img { width: 100%; height: 100%; object-fit: cover; }.- Every
section:position: relative; width: 100vw; height: 100svh; padding: 2em;. .hero: full-bleed background photo —background: url(<hero image>) no-repeat 50% 50%; background-size: cover;..about:color: #000; background-color: #e3e3db;(warm off-white), flex, centered both axes;.about p { width: 50%; text-align: center; }..outro: full-bleed background photo, same pattern as.hero.footer:position: relative; width: 100vw; height: 75svh; background-color: #0f0f0f; color: #fff; padding: 2em; display: flex; flex-direction: column; justify-content: space-between; align-items: center; overflow: hidden;. Theoverflow: hiddenis essential — it clips the particles while they idle below the footer and after they fall back out.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 face; if unavailable, a bold condensed sans fallback (e.g.sans-serif) is acceptable..copyright-info:width: 100%; display: flex; justify-content: space-between;(the two<p>s sit at opposite edges)..explosion-container:position: absolute; bottom: 0; left: 0; width: 100%; height: 200%; pointer-events: none;. The 200% height (twice the footer) matters — the fall-out check below measures against half of this container's height..explosion-particle-img:position: absolute; bottom: -200px; left: 50%; width: 150px; height: auto; object-fit: cover; transform: translateX(-50%); will-change: transform;. Every particle starts at the same spot: horizontally centered, 200px below the container's bottom edge (hidden by the footer'soverflow: hidden).
The explosion effect (vanilla physics — be exact)
There is no GSAP, no timeline, no ScrollTrigger. The animation is a custom particle engine. Reproduce these numbers exactly.
Config (module-scope constants)
const config = {
gravity: 0.25, // px/frame² added to vy every frame
friction: 0.99, // per-frame decay multiplier on vx, vy and spin
imageSize: 150, // particle width in px
horizontalForce: 20, // spread of the random horizontal launch velocity
verticalForce: 15, // base upward launch velocity
rotationSpeed: 10, // spread of the random spin speed
resetDelay: 500, // ms after landing before the effect may re-fire
};
imageParticleCount = 15; build animagePathsarray of the 15 particle image URLs (img1.jpg…img15.jpg).- Module state:
explosionContainer,footer,explosionTriggered = false,particles = [].
Particle class
Constructor takes the <img> element and initializes:
x = 0,y = 0(offsets in px from the CSS resting position)vx = (Math.random() - 0.5) * config.horizontalForce→ uniform in −10 … +10 px/framevy = -config.verticalForce - Math.random() * 10→ uniform in −15 … −25 px/frame (negative = upward)rotation = 0rotationSpeed = (Math.random() - 0.5) * config.rotationSpeed→ uniform in −5 … +5 deg/frame
update() — called once per frame:
this.vy += config.gravity;(gravity pulls down)this.vx *= config.friction; this.vy *= config.friction; this.rotationSpeed *= config.friction;(air drag on everything, including spin)this.x += this.vx; this.y += this.vy; this.rotation += this.rotationSpeed;- Write straight to the element: `
this.element.style.transform =translate(${this.x}px, ${this.y}px) rotate(${this.rotation}deg);— note this inline transform intentionally replaces the CSStranslateX(-50%)` once animation starts; that's how the original behaves.
Net motion: every card launches from the same point with a random up-and-out velocity, decelerates, apexes, then accelerates back down past its start point while its spin gradually damps out — a firework/confetti burst.
createParticles()
explosionContainer.innerHTML = ""and resetparticles = [].- For each of the 15 paths: create an
<img>, setsrc, add classexplosion-particle-img, set inlinestyle.width = "150px", append to the container. - Query all
.explosion-particle-imginside the container and map each element tonew Particle(element), stored inparticles.
explode()
- Guard:
if (explosionTriggered) return;then setexplosionTriggered = true. - Call
createParticles()(fresh DOM elements + fresh random velocities every burst). - Start a rAF loop:
- Each frame, call
update()on every particle. - Termination check: when every particle satisfies
particle.y > explosionContainer.offsetHeight / 2(i.e. it has fallen at least half the container's height — one full footer height — below its start),cancelAnimationFrame(...), stop the loop with afinishedflag, and afterconfig.resetDelay(500 ms) setexplosionTriggered = falseso the effect can fire again.
Scroll trigger
checkFooterPosition():
const footerRect = footer.getBoundingClientRect();
if (!explosionTriggered && footerRect.top <= window.innerHeight - footerRect.height * 0.5) {
explode();
}
i.e. the burst fires the moment at least half of the footer is inside the viewport.
init()
Run on DOMContentLoaded (with the usual document.readyState === "loading" guard so it also works if the script executes late):
- Query
.explosion-containerandfooter. - Preload all 15 images:
new Image().src = pathfor each. - Call
createParticles()once up front so the images exist in the DOM at rest (invisible — parked 200px below the footer and clipped). - Scroll listener, debounced with a 10 ms
setTimeout(clearTimeout+setTimeout(checkFooterPosition, 10)on everyscrollevent). - One initial
setTimeout(checkFooterPosition, 500)so the effect also fires if the page loads already scrolled to the footer. resizelistener that simply resetsexplosionTriggered = false.
Assets / images
17 images total, all landscape photos from one cohesive, moody, cinematic set (e.g. retro-futuristic concept-vehicle photography — dark, dramatic lighting; no real brands or logos):
- 1 hero background (~16:9): full-bleed cover background of the opening section.
- 1 outro background (~16:9): full-bleed cover background of the section just before the footer.
- 15 particle images (~3:2 landscape): rendered as small 150px-wide cards with
height: auto(native aspect preserved), these are the photos that explode out of the footer. They should read as a varied but matching collection.
Behavior notes
- The explosion re-fires: 500 ms after all particles have fallen out, the guard flag resets, so any subsequent scroll event while the footer is still half-visible triggers a brand-new burst (with new random velocities). Resizing the window also re-arms it.
- Particles are purely decorative:
pointer-events: noneon the container,will-change: transformon each card. - All physics is frame-based (per-rAF-tick), not time-based — no delta-time correction, matching the original.
- No reduced-motion handling in the original.
- The page is a normal scroll document (100svh + 100svh + 100svh + 75svh); nothing is pinned and scroll is native (no smooth-scroll library).