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: "Hanken Grotesk" (Google Fonts, import with @import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&display=swap");); if unavailable, fall back to a clean grotesk sans-serif. html, body { width:100%; height:100%; font-family:"Hanken Grotesk"; }.
  • 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.

Images

This component ships with 14 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/hypnotic-gallery-gsap-flip/img1.jpg
https://motionprompts.dev/c/hypnotic-gallery-gsap-flip/img10.jpg
https://motionprompts.dev/c/hypnotic-gallery-gsap-flip/img11.jpg
https://motionprompts.dev/c/hypnotic-gallery-gsap-flip/img12.jpg
https://motionprompts.dev/c/hypnotic-gallery-gsap-flip/img13.jpg
https://motionprompts.dev/c/hypnotic-gallery-gsap-flip/img14.jpg
… 8 more under https://motionprompts.dev/c/hypnotic-gallery-gsap-flip/

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--paper, --ink, --stone, --iris, --line. 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 *, html, body, body:has(.gallery.layout-3-gallery) .statement, body:has(.gallery.layout-1-gallery) .footer-mode span[data-layout="1"], body:has(.gallery.layout-2-gallery) .footer-mode span[data-layout="2"], body:has(.gallery.layout-3-gallery) .footer-mode span[data-layout="3"]** — 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.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone module that runs once, reaches into the page with document.querySelector, and drives one Lenis instance and a handful of GSAP tweens for the life of the tab — nothing it creates ever expects to be undone. React withdraws that guarantee, and it does so quietly: the gallery keeps flipping between layouts, but underneath it two Lenis instances, two self-scheduling raf loops and two sets of click listeners are now fighting over the same fourteen tiles.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. If the raf loop's handle isn't cancelled on the first pass, the second pass's new Lenis() and its own raf loop start alongside the first's, which never stopped — two smooth-scroll instances now both calling .raf() against the same wheel input, doubling the damping and making scroll feel sluggish and inconsistent in a way that has nothing to do with the numbers in the setup above. If the click listeners aren't torn down either, one click on 01/02/03 fires switchLayoutHandler twice: the second Flip.getState() records rects mid-way through the first call's still-running Flip.from, so the tiles jump partway through their move instead of completing the intended fast-out, long-settle glide. None of this reproduces in a production build, because React only double-invokes effects in development. Treat the cleanup as part of the effect, not an afterthought.

*(1) The entry point* — Nearly the entire file runs the instant the module is evaluated: new Lenis() and the lenis.stop() that keeps layout 01 from scrolling, the self-scheduling raf loop, gsap.registerPlugin(Flip, CustomEase, ScrollToPlugin), the CustomEase.create("hop", …) registration, every document.querySelector/querySelectorAll, and the three nav-item click listeners — none of it waits for DOMContentLoaded or load. Move all of it into a useEffect with an empty dependency array. gsap.registerPlugin and CustomEase.create can stay at module scope, above the component: re-registering either on a second mount is harmless, and the "hop" ease only needs to exist once. The one line that genuinely is gated on an event is the closing window.addEventListener("load", () => { if (activeLayout === "layout-2-gallery") handleScroll(); }) — and in a React app, which mounts only after its own bundle has finished loading, document.readyState is already "complete" by the time this effect runs, so load has already fired and that listener would never be called. Replace it with an immediate check: if document.readyState === "complete", call handleScroll() directly inside the effect; otherwise attach the load listener and remove it in the cleanup, so a StrictMode unmount landing before the real event doesn't leave a listener that fires into a component whose gallery/minimap lookups now resolve to nothing.

*(2) Element lookups* — document.querySelectorAll("nav .nav-item p"), document.querySelector(".gallery"), .gallery-container (captured but never read again — nothing to scope, just stop pulling it off document), .img-previews and .minimap all assume this component owns the page. Give the component a root ref around the <nav> and the .gallery-container, and resolve all of them off it. gallery.querySelectorAll(".img") inside switchLayoutHandler is already scoped to the gallery element itself, so it needs no change beyond gallery coming from the ref instead of document. This matters specifically because of the Flip mechanic: during the StrictMode remount two .gallery elements briefly coexist, and an unscoped Flip.getState()/Flip.from() pair can measure one copy and animate the other — which shows up as tiles that snap to their new layout with no transition at all, not as an error.

*(3) Cleanup* — Wrap the click listeners in a gsap.context scoped to the root ref, and revert that context in the cleanup:

useEffect(() => {
  const ctx = gsap.context((self) => {
    const items = rootRef.current.querySelectorAll("nav .nav-item p");
    // gallery, imgPreviews and minimap resolved the same way, off rootRef.current

    items.forEach((item) => {
      item.addEventListener("click", () => {
        if (!item.id) return;
        self.add(() => switchLayout(item.id));
      });
    });
  }, rootRef);

  return () => {
    ctx.revert();
    window.removeEventListener("scroll", handleScroll);
  };
}, []);

ctx.revert() has almost nothing to undo at mount — no tween exists until the first click — so its real job is catching a Flip or a fade that is still mid-flight when the component unmounts. That only works if every tween this component creates is actually attributed to the context, and none of them are created while the factory above is still synchronously running: switchLayoutHandler's Flip.from, the autoAlpha fade of imgPreviews/minimap, and the gsap.set(…, { clearProps: "y" }) pair all fire from inside a click callback, from the onComplete of the scroll-to-top tween in switchLayout, or — while layout 02 is active — from every scroll event handleScroll handles. All of those run after the factory has already returned, so gsap.context never attributes them to ctx unless each call site wraps its work in the one-argument self.add(() => { … }), the way the click listener above wraps switchLayout. Do the same at switchLayout's onComplete (onComplete: () => self.add(() => switchLayoutHandler(newLayout))) and inside handleScroll's body — otherwise a Flip that is still interpolating tile positions, or a parallax tween still writing y on the gallery, keeps running against tiles React has already thrown away.

The scroll listener needs its own line in the cleanup regardless of any of that: the script only calls window.removeEventListener("scroll", handleScroll) when switchLayoutHandler leaves layout 02 for 01 or 03. If the component unmounts *while* layout 02 is still active, that removal never runs, and handleScroll keeps firing on every scroll of a page whose gallery is gone — reading scrollHeight/offsetHeight off elements that are now detached. Remove it unconditionally in the effect's own cleanup, on top of whatever switchLayoutHandler already does during normal use.

Do not promote activeLayout to useState. Every read and write to it happens inside switchLayout/switchLayoutHandler, and nothing in JSX branches on its value — the active layout is expressed entirely by which class sits on the .gallery element, toggled directly by classList, exactly as the vanilla script does. Flip's technique depends on Flip.getState() capturing the *pre*-change rects and the class swap landing synchronously, in the same tick, with no render in between; routing the layout through React state would insert a commit between "record old positions" and "apply new class," and Flip would either animate from stale rects or find nothing changed to animate against. Keep activeLayout a plain variable closed over by the effect, the same way it is above.

Smooth scroll and the raf loop that drives it need their own explicit teardown, in an order that matters. Create the one Lenis instance inside the effect, keep the exact handle the self-scheduling loop's requestAnimationFrame call returns, and in the cleanup cancel that handle before calling lenis.destroy() — so no frame already queued calls .raf() on an instance that no longer exists:

let rafId;
const lenis = new Lenis();
lenis.stop(); // layout 01 must not scroll

function raf(time) {
  lenis.raf(time);
  rafId = requestAnimationFrame(raf);
}
rafId = requestAnimationFrame(raf);

// cleanup:
cancelAnimationFrame(rafId);
lenis.destroy();

Keep lenis.start()/lenis.stop() tied to entering/leaving layout 02 exactly as switchLayoutHandler already does — that logic doesn't change. What has to survive the port unchanged is the paired lenis.resize() call right after each start()/stop(): the CSS hides .img-previews with display:none whenever the gallery isn't in layout 02 (a body:not(:has(.gallery.layout-2-gallery)) rule), and that column's full height is the only thing giving the document anything to scroll. Lenis measures its scroll range on window resize, not on class changes or display toggles, so skipping the manual resize() call leaves the smoothed scroll range stale — typically zero — right after switching into layout 02, and the parallax handleScroll drives looks frozen until an unrelated window resize happens to correct it.