All components

Image Explosion Scroll Animation

JavaScript animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

As the footer scrolls into view, fifteen images burst upward from the bottom of the footer and arc back down under simulated gravity. Pure vanilla JS: a requestAnimationFrame loop drives a custom particle physics system (velocity, gravity, friction, rotation) with no GSAP, fired by a scroll listener that checks the footer's bounding rect against the viewport.

How it's built

Categoryfooter
Techvanilla JS
Complexitysection
Performance costlight
Mobile-safeyes

scroll explosion particles physics footer gravity raf image-reveal playful

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 requestAnimationFrameno 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>&copy; 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 asterisks in the paragraph are literal text characters (raw markdown-style emphasis left unrendered) — keep them.
  • .hero and .outro are empty; their imagery comes from CSS background.
  • .explosion-container starts 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 generic monospace fallback 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;. The overflow: hidden is 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's overflow: 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 an imagePaths array of the 15 particle image URLs (img1.jpgimg15.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/frame
  • vy = -config.verticalForce - Math.random() * 10 → uniform in −15 … −25 px/frame (negative = upward)
  • rotation = 0
  • rotationSpeed = (Math.random() - 0.5) * config.rotationSpeed → uniform in −5 … +5 deg/frame

update() — called once per frame:

  1. this.vy += config.gravity; (gravity pulls down)
  2. this.vx *= config.friction; this.vy *= config.friction; this.rotationSpeed *= config.friction; (air drag on everything, including spin)
  3. this.x += this.vx; this.y += this.vy; this.rotation += this.rotationSpeed;
  4. 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 CSS translateX(-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()
  1. explosionContainer.innerHTML = "" and reset particles = [].
  2. For each of the 15 paths: create an <img>, set src, add class explosion-particle-img, set inline style.width = "150px", append to the container.
  3. Query all .explosion-particle-img inside the container and map each element to new Particle(element), stored in particles.
explode()
  1. Guard: if (explosionTriggered) return; then set explosionTriggered = true.
  2. Call createParticles() (fresh DOM elements + fresh random velocities every burst).
  3. Start a rAF loop:
  4. Each frame, call update() on every particle.
  5. 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 a finished flag, and after config.resetDelay (500 ms) set explosionTriggered = false so 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):

  1. Query .explosion-container and footer.
  2. Preload all 15 images: new Image().src = path for each.
  3. Call createParticles() once up front so the images exist in the DOM at rest (invisible — parked 200px below the footer and clipped).
  4. Scroll listener, debounced with a 10 ms setTimeout (clearTimeout + setTimeout(checkFooterPosition, 10) on every scroll event).
  5. One initial setTimeout(checkFooterPosition, 500) so the effect also fires if the page loads already scrolled to the footer.
  6. resize listener that simply resets explosionTriggered = 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: none on the container, will-change: transform on 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).