Photo Dump Scroll Scatter — Pinned Ring of Cards that Fly In and Out
Goal
Build a full-screen, pinned "photo dump" gallery. A dark section pins in place for six viewport heights of scroll, split into four segments. In each segment, 15 rotated photo cards are scattered in a loose ring around a centered serif heading. Every time you cross a segment boundary, the whole set re-shuffles: the current 15 cards fly out to their nearest screen edge (accelerating away) while 15 fresh cards from the next image set fly in from the edges (decelerating into place) with a half-second overlap, and the heading cross-fades to a new phrase. The star effect is this choreographed scatter-out / scatter-in card swap driven by scroll position while the section stays pinned.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scroll. No other plugins, no framework:
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
Register once: gsap.registerPlugin(ScrollTrigger);. Run everything inside document.addEventListener("DOMContentLoaded", …). Must run in a fresh Vite + npm project. Ship one index.html (<link rel="stylesheet" href="./styles.css"> and <script type="module" src="./script.js">), one styles.css, one ES-module script.js.
Layout / HTML
Three stacked full-screen <section>s. Class names are load-bearing — the JS/CSS query them. The cards are not in the markup; the JS creates and injects all of them into .gallery.
<section class="intro">
<h1>Time loosens its grip and the stack begins to shift</h1>
</section>
<section class="gallery">
<h1></h1>
</section>
<section class="outro">
<h1>Eventually, the stack settles and the scroll continues</h1>
</section>
.introand.outroare simple dark panels with a centered heading each — they exist only to give scroll runway before and after the pinned gallery..gallery > h1starts empty; the JS fills its text from the headings array and animates it.- All
.cardelements are generated at runtime and appended into.gallery(see GSAP section).
Styling
Font (Google Fonts): Instrument Serif (import italic axis too):
@import url("https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap");
Palette (CSS variables on :root):
--base-100: #fff— heading text color (white).--base-200: #4a4a4a— the card's border color (mid-gray frame).--base-300: #141414— the.gallerybackground (near-black).--base-400: #0f0f0f— the.intro/.outrobackground (slightly darker near-black).
Global / reset:
* { margin:0; padding:0; box-sizing:border-box; }body { font-family:"Instrument Serif", sans-serif; }h1 { font-size: clamp(3rem, 5vw, 7vw); font-weight:500; line-height:0.9; letter-spacing:-0.025rem; }
Sections:
section { position:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; color:var(--base-100); overflow:hidden; }section h1 { width:45%; text-align:center; will-change:opacity; z-index:2; }— the heading is constrained to 45% width, centered, and sits above the cards (cards have no z-index, soz-index:2keeps the heading on top)..intro, .outro { background-color:var(--base-400); }.gallery { background-color:var(--base-300); }
Card (critical — this is the exact box the JS positions):
.card {
position: absolute;
width: 250px;
height: 300px;
border-radius: 1rem;
border: 0.5rem solid var(--base-200);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.25);
will-change: transform;
overflow: hidden;
}
.card img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.5rem;
}
Cards are position:absolute inside .gallery and are placed entirely with inline left/top/rotation written by GSAP — never by CSS coordinates. Each is a fixed 250×300 portrait card (roughly 5:6) with a thick gray frame and rounded corners.
Responsive:
@media (max-width:1000px) { section h1 { width:100%; padding:2rem; } }— heading relaxes to full width on narrow screens.
GSAP effect (be exhaustive)
Smooth scroll wiring (Lenis + GSAP ticker)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Config constants
const CONFIG = {
cardCount: 15,
cardWidth: 250,
cardHeight: 300,
animationDuration: 0.75,
animationOverlap: 0.5,
headingFadeDuration: 0.5,
headings: [
"Order is temporary while you're passing through",
"Memories shuffle like cards in an endless deck",
"Each moment scatters as another takes its place",
"The fragments float before settling once more",
],
};
Viewport + state
let viewport = {
centerX: window.innerWidth / 2,
centerY: window.innerHeight / 2,
rangeMin: Math.min(window.innerWidth, window.innerHeight) * 0.35,
rangeMax: Math.min(window.innerWidth, window.innerHeight) * 0.7,
};
let state = { activeCards: [], currentSection: 0, isAnimating: false };
updateViewport() recomputes these four values from the current window size. rangeMin/rangeMax are the inner and outer radii of the scatter ring (35% and 70% of the smaller viewport dimension).
Ring placement — createCards(setNumber)
Creates and returns an array of 15 card objects. For each i (0–14):
- Make a
div.card, give it a child<img>whosesrcis/c/poly-app-photo-dump/set${setNumber}/img${i + 1}.jpg(image sets numbered 1–4; each set has 15 images). - Pick a random polar position around the viewport center:
``js const angle = Math.random() * Math.PI * 2; // 0..2π const radius = viewport.rangeMin + Math.random() * (viewport.rangeMax - viewport.rangeMin); const centerX = viewport.centerX + Math.cos(angle) * radius; const centerY = viewport.centerY + Math.sin(angle) * radius; ``
- Place the card so its center lands on
(centerX, centerY), with a random tilt:
``js gsap.set(card, { left: centerX - CONFIG.cardWidth / 2, top: centerY - CONFIG.cardHeight / 2, rotation: Math.random() * 50 - 25, // -25°..+25° }); ``
- Append to
.gallery, and push{ element: card, centerX, centerY }(remember each card's home ring point for later).
Nearest-edge target — getEdgePosition(centerX, centerY)
Given a card's home center, compute an off-screen {x, y} (used directly as left/top) on whichever screen edge is closest to that point:
const distances = {
left: centerX,
right: window.innerWidth - centerX,
top: centerY,
bottom: window.innerHeight - centerY,
};
const minDistance = Math.min(...Object.values(distances));
const offsetVariation = () => (Math.random() - 0.5) * 400; // -200..+200 jitter
- Nearest = left →
x: -300 - Math.random() * 200(i.e. -300..-500),y: centerY - cardHeight/2 + offsetVariation(). - Nearest = right →
x: window.innerWidth + 50 + Math.random() * 200,y: centerY - cardHeight/2 + offsetVariation(). - Nearest = top →
x: centerX - cardWidth/2 + offsetVariation(),y: -400 - Math.random() * 200. - Nearest = bottom (fallthrough) →
x: centerX - cardWidth/2 + offsetVariation(),y: window.innerHeight + 50 + Math.random() * 200.
So a card exits toward, and enters from, the edge it is nearest to, with vertical/horizontal jitter so the flock doesn't move in lockstep.
The card swap — animateCards(exitingCards, enteringCards) (the star effect)
Returns a single gsap.timeline() built as follows:
Exiting cards — for each currently-visible card, tween it OUT to its nearest edge, added at position 0:
tl.to(element, {
left: targetEdge.x, // getEdgePosition(centerX, centerY)
top: targetEdge.y,
rotation: Math.random() * 180 - 90, // -90°..+90° tumble
duration: 0.75, // CONFIG.animationDuration
ease: "power2.in", // accelerate away
onComplete: () => element.remove(), // detach from DOM when off-screen
}, 0);
Entering cards — for each of the 15 new cards, first snap it to an edge (its getEdgePosition from its own home center), then tween it IN to its home ring position, added at position 0.5 (CONFIG.animationOverlap):
gsap.set(element, { // start off-screen at the edge
left: targetEdge.x,
top: targetEdge.y,
rotation: Math.random() * 180 - 90,
});
tl.to(element, {
left: centerX - CONFIG.cardWidth / 2, // land centered on its ring point
top: centerY - CONFIG.cardHeight / 2,
rotation: Math.random() * 50 - 25, // settle to -25°..+25°
duration: 0.75, // CONFIG.animationDuration
ease: "power2.out", // decelerate into place
}, 0.5); // begins halfway through the exit
Net timing: exit tweens run t=0→0.75; enter tweens run t=0.5→1.25, so the new cards start streaming in while the old ones are still leaving — a continuous cross-flow. Note the cards animate left/top (layout position), plus rotation (transform).
Heading cross-fade — animateHeading(newText)
Returns a timeline that fades the gallery heading out, swaps its text, and fades it back:
gsap.timeline()
.to(galleryHeading, { opacity: 0, duration: 0.5, ease: "power2.inOut" })
.call(() => { galleryHeading.textContent = newText; })
.to(galleryHeading, { opacity: 1, duration: 0.5, ease: "power2.inOut" });
Total ~1.0s, running in parallel with the card swap.
Segment mapping — getSectionIndex(progress)
if (progress < 0.25) return 0;
if (progress < 0.5) return 1;
if (progress < 0.75) return 2;
return 3;
Four equal quarters of the pinned scroll → sections 0,1,2,3 → image sets 1,2,3,4.
The pinned ScrollTrigger
ScrollTrigger.create({
trigger: ".gallery",
start: "top top",
end: () => `+=${window.innerHeight * 6}`, // pin for 6 viewport heights
pin: true,
pinSpacing: true,
onUpdate: ({ progress }) => {
if (state.isAnimating) return; // ignore updates mid-swap
const targetSection = getSectionIndex(progress);
if (targetSection === state.currentSection) return; // only fire on a boundary crossing
state.isAnimating = true;
const newCards = createCards(targetSection + 1); // build the next 15 cards (already at their ring spots)
Promise.all([
animateCards(state.activeCards, newCards).then(), // swap cards
animateHeading(CONFIG.headings[targetSection]).then(), // cross-fade heading
]).then(() => {
state.activeCards = newCards;
state.currentSection = targetSection;
state.isAnimating = false; // unlock for the next boundary
});
},
});
Key behaviors: the section pins in place while you scroll six viewport heights; the swap is not scrubbed — crossing a quarter boundary triggers a self-contained ~1.25s animation once, and the isAnimating lock prevents overlapping swaps (fast scrolls just queue the next boundary until the current swap finishes). Because createCards injects the new set at its ring positions and animateCards immediately gsap.sets them to the edges, the incoming cards are only ever seen flying in from the edges.
Initial paint
state.activeCards = createCards(1); // set 1 scattered around center
galleryHeading.textContent = CONFIG.headings[0]; // first phrase
gsap.set(galleryHeading, { opacity: 1 });
Resize handling — reinitialize()
window.addEventListener("resize", () => {
state.activeCards.forEach(({ element }) => element.remove());
updateViewport();
state.activeCards = createCards(state.currentSection + 1); // rebuild current set at new ring
ScrollTrigger.refresh();
});
Assets / images
60 photo cards total = 4 sets × 15 images. Source images are landscape-oriented photographs (≈4:3, wider than tall), but each is displayed in a fixed 250×300 portrait card with object-fit: cover — so the middle vertical slice of each landscape shot is what actually shows; exact crop doesn't matter. File layout the JS expects: set1/img1.jpg … set1/img15.jpg, set2/img1.jpg … set2/img15.jpg, etc. No brands, logos, or text anywhere.
The photos are general scenic / atmospheric stock-style imagery rather than one tight subject per set — a loose mix of landscapes, cityscapes, and interiors that reads as a warm, cinematic "photo dump". Suggested roles by set:
- Set 1 (segment 1) — 15 scenic photographs (any cohesive-feeling grab bag).
- Set 2 (segment 2) — 15 mixed-subject atmospheric photos, loosely themed around sky / flight and neon night cities. Verified contents include: an aerial view of a turquoise river meandering through bright-green grassland under a pale blue sky (teal + green); an aerial shot above a blanket of white cumulus clouds with a clear blue sky above (white + soft blue); a night sky with a bright moon and stars over dramatic clouds lit fiery red-orange against deep navy; an airplane silhouette flying across a blazing red-orange sunset sky; a sunset seen through an oval airplane window, warm orange-and-blue sky over a low cloud layer; and several neon-lit cyberpunk city streets at night/dusk in magenta/purple tones — a glowing car on a wet foggy avenue, two figures beside a red-lit neon monolith, and a tree-lined boulevard leading to backlit neon towers. Dominant colors across the set span teals and greens, cool blues and whites, fiery reds and oranges, and electric magentas/purples — no single unifying palette.
- Set 3 (segment 3) — 15 scenic photographs (any cohesive-feeling grab bag).
- Set 4 (segment 4) — 15 scenic photographs (any cohesive-feeling grab bag).
If you have fewer than 15 per set, repeat images to fill each set.
Behavior notes
- Not scrubbed: the pin holds the section; each segment crossing plays a one-shot card swap + heading cross-fade, gated by an
isAnimatingflag so swaps never overlap. Scrolling back up re-crosses boundaries and swaps in the appropriate set the same way. - Heading always stays centered and on top (
z-index:2); cards render beneath it. - Cards are
position:absoluteand animated vialeft/top+rotation; keepwill-change:transformon.cardandwill-change:opacityon the heading. lagSmoothing(0)+ the function-basedendkeep the pin distance correct;ScrollTrigger.refresh()on resize plusreinitialize()re-derive ring positions for the new viewport.- No SplitText, no CustomEase, no lerp/rAF loop, no Three.js — just ScrollTrigger pinning, Lenis smooth scroll, and per-boundary GSAP timelines.
</content> </invoke>