Hypnotic Image Gallery — GSAP Flip Layout Morph (3 layouts) + Lenis Scroll List
Goal
Build a full-viewport image gallery of 14 tiles that morphs between three completely different layouts when you click the numbered nav items (01 / 02 / 03). The star effect is GSAP Flip: on every switch it records the on-screen state of all 14 tiles, swaps a single layout class on the gallery, then animates every tile smoothly from its old position/size to its new one with a custom "hop" ease and a tiny per-tile stagger. Layout 01 is a scattered editorial grid, layout 02 is a small vertical column that becomes a Lenis-smoothed vertical scroll list (a framed "minimap" box and a hidden full-size preview column fade in and drift as you scroll), and layout 03 piles all 14 tiles into a single stacked deck in the top-right corner.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins Flip, CustomEase, and ScrollToPlugin, and lenis for smooth scroll. Imports:
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { CustomEase } from "gsap/CustomEase";
import { ScrollToPlugin } from "gsap/ScrollToPlugin";
import Lenis from "lenis";
gsap.registerPlugin(Flip, CustomEase, ScrollToPlugin);
Start Lenis immediately with a self-driving rAF loop (do NOT tie it to gsap.ticker here — it runs its own loop):
const lenis = new Lenis();
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
Layout / HTML
Class/id names are load-bearing — the JS and the Flip layouts query them.
<nav>
<div class="nav-item"><p>Motionprompts</p></div>
<div class="nav-item"><p id="layout-1-gallery">01</p></div>
<div class="nav-item"><p id="layout-2-gallery">02</p></div>
<div class="nav-item"><p id="layout-3-gallery">03</p></div>
<div class="nav-item"><p>Menu</p></div>
</nav>
<div class="gallery-container">
<div class="gallery layout-1-gallery">
<div class="img" id="img1"><img src="…/img1.jpg" alt="" /></div>
<div class="img" id="img2"><img src="…/img2.jpg" alt="" /></div>
…
<div class="img" id="img14"><img src="…/img14.jpg" alt="" /></div>
</div>
</div>
<div class="minimap"></div>
<div class="img-previews">
<img src="…/img1.jpg" alt="" />
…
<img src="…/img14.jpg" alt="" /> <!-- all 14, same order -->
</div>
- The gallery starts with both classes
gallery layout-1-gallery. There are exactly 14.imgtiles, idsimg1…img14in DOM order; each wraps one<img>. .minimapis an empty framed box (no content)..img-previewsis a hidden column that repeats all 14 images at full size — it is invisible (opacity:0) but its tall absolute height is what makes the page scrollable in layout02(the scroll handler reads itsscrollHeight). Keep the same 14 images in the same order.- Nav labels: keep
Motionprompts,01,02,03,Menu(neutral demo text — no real brand). Only the three numbered<p>carry ids and are clickable layout switches.
Styling
- Font: "PP Neue Montreal" (import from
https://fonts.cdnfonts.com/css/pp-neue-montreal); if unavailable, fall back to a clean grotesksans-serif.html, body { width:100%; height:100%; font-family:"PP Neue Montreal"; }. - Global reset
* { margin:0; padding:0; box-sizing:border-box; }. Colors are default black text on the page's default white background (no explicit palette).img { width:100%; height:100%; object-fit:cover; }— every tile is a hard cover-crop of its box. nav:position:fixed; top:0; left:0; width:100%; padding:0.75em 2em; display:flex; z-index:2;. Each.nav-item { flex:1; }..nav-item p { text-transform:uppercase; font-size:13px; font-weight:500; padding:1em 0.25em; cursor:pointer; }..gallery-container:width:100%; height:100%; padding-top:4em;.
Layout 01 — scattered grid (.gallery.layout-1-gallery): position:relative; width:100%; height:100%; transform:translateX(0%);. Tiles are .gallery.layout-1-gallery .img { position:absolute; width:100px; height:125px; } and placed by percentage rows (top) and columns (left/right):
- Rows:
top:0%→ img1–img4;top:25%→ img5–img8;top:50%→ img9,img10;top:75%→ img11–img14. - Columns:
left:2em→ img1,img5,img11;left:15%→ img2;left:25%→ img6;left:45%→ img3,img9;left:65%→ img4,img10,img12;left:75%→ img13;right:15%→ img7;right:2em→ img8,img14.
Layout 02 — vertical column (.gallery.layout-2-gallery): padding-top:0.5em; position:fixed; top:25%; left:10%; transform:translateX(0%);. Its .img are width:75px; height:100px; margin-bottom:1em; with no absolute positioning, so they stack as a normal vertical column in DOM order.
Layout 03 — stacked deck (.gallery.layout-3-gallery): position:relative; width:100%; height:100%; transform:translateX(0%);. Its .img are position:absolute; top:4em; right:4em; width:300px; height:400px; — all 14 share the same corner coordinates, so they pile into one big overlapping stack in the top-right (later ids on top).
Minimap .minimap: position:fixed; top:25%; left:12.5%; transform:translateX(-50%); width:140px; height:90px; border:1px solid #000; border-radius:2px; z-index:2; visibility:hidden; opacity:0; — a small empty framed rectangle, hidden until layout 02.
Preview column .img-previews: position:absolute; top:25%; left:50%; transform:translateX(-50%); width:30%; opacity:0;. .img-previews img { width:600px; height:700px; padding:1em 0; } — a tall stack of 14 big images, permanently invisible; its job is purely to give the document its scroll height.
Include the Lenis helper CSS (.lenis.lenis-smooth { scroll-behavior:auto !important; }, .lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior:contain; }, .lenis.lenis-stopped { overflow:hidden; }, .lenis.lenis-smooth iframe { pointer-events:none; }).
GSAP effect (be exhaustive)
CustomEase "hop"
Register one custom ease used for every Flip transition:
CustomEase.create("hop", "M0,0 C0.028,0.528 0.129,0.74 0.27,0.852 0.415,0.967 0.499,1 1,1");
This is a fast-out / long-settle curve — it rushes ~85% of the way early, then eases into the final position with a gentle tail (the "hypnotic" glide).
State + refs
const items = document.querySelectorAll("nav .nav-item p");
const gallery = document.querySelector(".gallery");
const galleryContainer = document.querySelector(".gallery-container");
const imgPreviews = document.querySelector(".img-previews");
const minimap = document.querySelector(".minimap");
let activeLayout = "layout-1-gallery";
Click → switchLayout(newLayout)
Each of the three numbered <p> gets a click listener; if it has an id, call switchLayout(item.id).
function switchLayout(newLayout) {
if (newLayout === activeLayout) return;
// Special case: leaving layout 02 while scrolled down → smooth-scroll back to top first
if (activeLayout === "layout-2-gallery" && window.scrollY > 0) {
gsap.to(window, {
scrollTo: { y: 0 },
duration: 0.5,
ease: "power3.out",
onComplete: () => switchLayoutHandler(newLayout),
});
} else {
switchLayoutHandler(newLayout);
}
}
So if you're scrolled inside layout 02 and click 01/03, it first uses ScrollToPlugin to glide the window back to y:0 over 0.5s power3.out, and only then runs the Flip (prevents morphing from a scrolled offset).
The Flip morph — switchLayoutHandler(newLayout)
This is the core. Capture → swap class → Flip.from:
function switchLayoutHandler(newLayout) {
const state = Flip.getState(gallery.querySelectorAll(".img")); // 1. record current rects of all 14 tiles
gallery.classList.remove(activeLayout); // 2. swap the single layout class…
gallery.classList.add(newLayout); // …tiles jump to their new CSS positions/sizes
let staggerValue = 0.025; // 3. default per-tile stagger
if (
(activeLayout === "layout-1-gallery" && newLayout === "layout-2-gallery") ||
(activeLayout === "layout-3-gallery" && newLayout === "layout-2-gallery")
) {
staggerValue = 0; // collapsing INTO the column → no stagger (all at once)
}
Flip.from(state, { // 4. animate old rects → new rects
duration: 1.5,
ease: "hop",
stagger: staggerValue,
});
activeLayout = newLayout;
…
}
Exact values:
Flip.fromduration1.5s,ease:"hop". Flip interpolates each tile's position AND size (100×125 ↔ 75×100 ↔ 300×400) between layouts — tiles slide, scale, and re-pile in one continuous move.stagger:0.025s per tile in DOM order for most transitions, giving a rippling cascade; but it's forced to0(all tiles move simultaneously) specifically when going 01→02 or 03→02, so collapsing into the vertical column snaps together rather than trailing.
Layout-02 chrome fade + scroll wiring
Immediately after starting the Flip, toggle the extra UI depending on the target layout:
if (newLayout === "layout-2-gallery") {
gsap.to([imgPreviews, minimap], { autoAlpha: 1, duration: 0.3, delay: 0.5 });
window.addEventListener("scroll", handleScroll);
} else {
gsap.to([imgPreviews, minimap], { autoAlpha: 0, duration: 0.3 });
window.removeEventListener("scroll", handleScroll);
gsap.set(gallery, { clearProps: "y" });
gsap.set(minimap, { clearProps: "y" });
}
// mark the active nav item (class only, no built-in style)
items.forEach((item) => item.classList.toggle("active", item.id === newLayout));
- Entering
02: fade the minimap + preview column in viaautoAlpha:1(opacity + visibility), duration 0.3s, delay 0.5s (so they appear as the Flip is settling), and attach the scroll handler. - Leaving
02(to01or03):autoAlpha:0over 0.3s, detach the scroll handler, andclearProps:"y"on bothgalleryandminimapto wipe any scroll-drivenytransform so they return to their untranslated home.
Scroll handler (layout 02 only) — parallax of gallery + minimap
While in layout 02, handleScroll maps page scroll to a y translate on the fixed gallery column (so it scrolls even though it's position:fixed) and drifts the minimap down slightly:
function handleScroll() {
if (activeLayout !== "layout-2-gallery") return;
const imgPreviewsHeight = imgPreviews.scrollHeight; // tall hidden column = scrollable range
const galleryHeight = gallery.scrollHeight;
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const scrollFraction = scrollY / (imgPreviewsHeight - windowHeight);
const galleryTranslateY = -scrollFraction * (galleryHeight - windowHeight) * 1.525;
const minimapTranslateY = scrollFraction * (windowHeight - minimap.offsetHeight) * 0.425;
gsap.to(gallery, { y: galleryTranslateY, ease: "none", duration: 0.1 });
gsap.to(minimap, { y: minimapTranslateY, ease: "none", duration: 0.1 });
}
scrollFractionis 0→1 across the scrollable range defined by the hidden preview column.- The gallery column moves up proportionally, over-scrolled by the magic factor
1.525so the small 75×100 tiles travel further than the page (fast list feel). The minimap drifts down by up to(windowHeight − 90) × 0.425. - Both are
gsap.towithease:"none",duration:0.1— a short smoothing tween each scroll event, riding on top of Lenis's already-smoothed scroll.
On load
window load → if activeLayout === "layout-2-gallery" call handleScroll() once (no-op on the default 01, but keeps state correct).
Assets / images
14 distinct images, role = *gallery tiles* (img1…img14), reused verbatim (same files, same order) in the hidden full-size preview column. Because every <img> is object-fit:cover, any source aspect works, but they render in portrait-ish boxes (100×125, 75×100, 300×400), so portrait or square framing crops best. For a cohesive editorial feel, use a varied set on dark/neutral backgrounds: e.g. a moody macro photograph, a few abstract 3D renders (glossy forms, stacked discs), several dramatically-lit product still-lifes (a camera, a wristwatch, a VR headset, a portable speaker, a vintage radio), and a couple of interior/architecture detail shots. No brand logos or real client marks. Provide all 14 as separate files.
Behavior notes
- Entirely click + scroll driven — no autoplay, no hover. The three numbered nav items are the only triggers; the Flip plays on each switch.
- The scroll listener is added only while in layout
02and removed on exit;clearProps:"y"resets the parallax so re-entering01/03starts clean. - The
.activeclass toggled on nav items is a state hook only (no visual style is required unless you want to add one). - Guard re-entry:
switchLayoutearly-returns if the requested layout equals the active one. GSAP naturally overwrites in-flight tweens if the user clicks mid-morph. - Layout
02's scrollability comes entirely from the invisible.img-previewscolumn's height — keep it present and full-size even thoughopacity:0.