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).

Images

This component ships with 17 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/image-explosion-scroll-animation/hero.jpg
https://motionprompts.dev/c/image-explosion-scroll-animation/img1.jpg
https://motionprompts.dev/c/image-explosion-scroll-animation/img10.jpg
https://motionprompts.dev/c/image-explosion-scroll-animation/img11.jpg
https://motionprompts.dev/c/image-explosion-scroll-animation/img12.jpg
https://motionprompts.dev/c/image-explosion-scroll-animation/img13.jpg
… 11 more under https://motionprompts.dev/c/image-explosion-scroll-animation/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--ink, --paper, --paper-bright, --safelight, --ink-soft. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.

Adapting this to React

Everything above describes a mount(config) / destroy() pair that is already close to a useEffect: mount finds .explosion-container and footer, preloads the fifteen particle images, seeds the particle DOM once at rest, wires the scroll and resize listeners plus three setTimeouts, and returns a function that walks every one of those back off. The dispatch at the bottom of the file — the window.MP check and the readyState guard — is the part that has nothing to do with how React schedules work.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. If mount()'s own return value isn't the thing you hand back as the effect's cleanup, the first mount's scroll and resize listeners on window are never removed — they aren't scoped to any element StrictMode's remount replaces, so they just keep running, each pair closed over its own explosionTriggered flag and particles array. Revisit this route without wiring the cleanup and every mount adds one more live scroll listener, permanently, each independently deciding off its own stale footer/explosionContainer references whether to fire a burst. This will not reproduce in a production build, because React only double-invokes in development.

*(1) The entry point* — Delete the whole dispatch block. window.MP && window.MP.register is this catalogue's own editor hook; window.MP is undefined in a React host, so execution always falls to the else. And the document.readyState === "loading" guard around the DOMContentLoaded listener is dead weight once mount()'s body runs inside a useEffect, which already fires after the DOM is committed. What's left is a direct call:

useEffect(() => {
  const destroy = mount({ ...DEFAULTS });
  return destroy;
}, []);

*(2) Element lookups* — mount() opens with document.querySelector(".explosion-container") and document.querySelector("footer"), which assumes it owns the whole document — true enough here, since this component *is* the whole page (hero, about, outro, footer, in that order), but the two lookups should still be scoped to a ref on whatever root the four sections render under, not to document, so a StrictMode remount can't hand the footer-visibility check the copy of the subtree that's on its way out. Pass that root node into mount (or have it query inside rootRef.current instead of document) for both lookups. Leave the scroll and resize listeners on window unscoped, though: the footer-visibility check reads getBoundingClientRect() against the viewport, not against any scoped container, so there's nothing here for the root ref to own.

*(3) Cleanup* — mount()'s returned function already does the complete job: stopLoop() cancels the exact animationId the running animate() loop holds, and the three timers are cleared by name — firstCheck (the half-second initial check, for a page that loads already scrolled down to the footer), checkTimeout (the ten-millisecond scroll debounce), and resetTimer (the delay before explosionTriggered flips back so the burst can re-arm) — before the scroll/resize listeners come off window and explosionContainer.innerHTML is cleared. Return it unmodified: there is no legacy shape here to translate into something more idiomatic, and no GSAP context or Lenis instance to fold it into, since this component uses neither.

One thing worth being deliberate about rather than "fixing": explosionTriggered, particles, animationId and the three timer handles all stay plain variables closed over inside mount(), exactly as in the original — they never become useState. Promoting any of them would either do nothing (the effect's dependency array is empty, so nothing re-runs) or re-fire the whole setup on every burst, since nothing outside this effect ever needs to read them.