All components

Hypnotic Image Gallery (GSAP Flip)

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

Open live demo ↗ Raw prompt (.md)

What it does

A 14-image still-life gallery for a set-design studio (Plinth) that morphs between three layouts — Grid, Index, Stack — when you click the nav items; GSAP Flip records the DOM state, swaps a layout class, and animates every image from its old to new position with a custom hop ease and staggered timing. The Index layout becomes a Lenis-smoothed vertical scroll list with a floating minimap and preview column driven by a scroll handler.

How it's built

Categorygallery
Techgsap, lenis
GSAP pluginsFlip, CustomEase, ScrollToPlugin
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

gallery flip layout-transition gsap lenis grid click editorial minimal

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

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 .img tiles, ids img1img14 in DOM order; each wraps one <img>.
  • .minimap is an empty framed box (no content).
  • .img-previews is 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 layout 02 (the scroll handler reads its scrollHeight). 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 grotesk sans-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.from duration 1.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 to 0 (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 via autoAlpha:1 (opacity + visibility), duration 0.3s, delay 0.5s (so they appear as the Flip is settling), and attach the scroll handler.
  • Leaving 02 (to 01 or 03): autoAlpha:0 over 0.3s, detach the scroll handler, and clearProps:"y" on both gallery and minimap to wipe any scroll-driven y transform 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 });
}
  • scrollFraction is 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.525 so 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.to with ease:"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* (img1img14), 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 02 and removed on exit; clearProps:"y" resets the parallax so re-entering 01/03 starts clean.
  • The .active class toggled on nav items is a state hook only (no visual style is required unless you want to add one).
  • Guard re-entry: switchLayout early-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-previews column's height — keep it present and full-size even though opacity:0.