Aduok Code

A Scroll-Triggered Bento Gallery That Zooms to Fullscreen with GSAP Flip

Introduction

This gallery starts life as a tidy bento grid — eight images of mismatched sizes tiled into a single collage — and as the page scrolls, it zooms outward until every image is stacked fullscreen, one on top of the next. There is no manual keyframing of eight separate elements. Instead, two different CSS Grid layouts are defined for the exact same markup, and GSAP's Flip plugin is asked to figure out, and animate, the difference between them.

The technique rests on four ideas: a compact "bento" grid defined with named grid-area placements — a second, "final" grid defined on the same elements using only a class toggle, so it can be measured without ever being shown to the user — GSAP Flip diffing the before/after bounding boxes of every card and generating the tween that morphs one into the other — and ScrollTrigger scrubbing that tween's progress directly to scroll position, with the gallery pinned in place while it plays.

What you will learn

How to define two entirely different CSS Grid layouts on the same markup using a single toggled class — how GSAP's Flip plugin captures a "before" and "after" state and generates the animation between them without you writing per-element keyframes — how to scrub that animation to scroll position with ScrollTrigger while pinning the gallery in place — and how to keep the effect resilient to window resizing and to users who prefer reduced motion.

01
Part One
Two Grid States, One Markup

Step 1 — A Compact Grid and a Fullscreen Grid, Sharing the Same Cards

The eight .gallery__item elements never move in the DOM and never get individual inline styles written by hand. Instead, the default .gallery--bento class places each of the eight cards into an irregular grid using grid-area line coordinates, producing the collage look. A second rule, .gallery--bento.gallery--final, only changes the grid's column and row tracks — from 32.5vw columns and 23vh rows to full 100vw columns and 49.5vh rows — so the exact same eight grid-area placements now describe a fullscreen, one-card-per-row stack instead.

CSS
.gallery--bento {
  display: grid;
  gap: 1vh;
  grid-template-columns: repeat(3, 32.5vw);
  grid-template-rows: repeat(4, 23vh);
  justify-content: center;
  align-content: center;
}

.gallery--bento .gallery__item:nth-child(1) { grid-area: 1 / 1 / 3 / 2; }
.gallery--bento .gallery__item:nth-child(2) { grid-area: 1 / 2 / 2 / 3; }
/* …remaining six placements stay identical in both states… */

/* Only the track sizing changes — placements are untouched */
.gallery--bento.gallery--final {
  grid-template-columns: repeat(3, 100vw);
  grid-template-rows: repeat(4, 49.5vh);
  gap: 1vh;
}
ClassGrid tracksRole
.gallery--bento3 × 32.5vw cols / 4 × 23vh rowsDefault compact collage, shown to the user
.gallery--bento.gallery--final3 × 100vw cols / 4 × 49.5vh rowsNever actually shown — toggled on for one frame purely so Flip can measure it
02
Part Two
Capturing States with Flip

Step 2 — Measuring "Before", Then "After", Without Ever Rendering "After"

Flip stands for First, Last, Invert, Play — it reads an element's bounding box before a change (First), reads it again after the change (Last), and generates a tween that starts each element offset back at its First position before animating to its Last one (Invert, Play). Here, the "change" is instantaneous: the gallery--final class is added, Flip.getState() reads every card's new fullscreen bounding box, and the class is immediately removed again — all inside the same synchronous function, so the user never sees the fullscreen layout flash on screen. Flip is left holding accurate start and end coordinates for all eight cards.

JS
galleryEl.classList.add('gallery--final')
const finalState = Flip.getState(galleryItems)   // read the fullscreen boxes
galleryEl.classList.remove('gallery--final')      // instantly revert — nothing is painted

const flipAnimation = Flip.to(finalState, {
  simple: true,
  ease: 'expoScale(1, 5)'
})

Flip never needs to know the grid contains eight differently-sized, differently-positioned cards. It only ever asks two questions — where was this element, and where is it now — for each card independently, then builds the tween. The layout complexity lives entirely in CSS; the JS stays generic.

03
Part Three
Scrubbing the Flip with ScrollTrigger

Step 3 — Tying the Tween's Progress to the Scrollbar

On its own, Flip.to() would just play once on a timer. Wrapping it in a gsap.timeline() with a scrollTrigger config turns it into something scrubbable: scrub: true locks the timeline's progress directly to how far the user has scrolled between start and end, and pin: galleryEl.parentNode freezes the gallery in the viewport for that entire scroll distance so the zoom reads as one continuous gesture rather than the gallery scrolling away mid-animation.

JS
const scrollTimeline = gsap.timeline({
  scrollTrigger: {
    trigger: galleryEl,
    start: 'center center',
    end: '+=100%',
    scrub: true,
    pin: galleryEl.parentNode
  }
})

scrollTimeline.add(flipAnimation)
A guard clause worth double-checking

Before registering any of this, confirm the plugins actually loaded — check typeof Flip and typeof ScrollTrigger, not a made-up nested property like gsap.core.Flip. A guard that never resolves true will silently disable the entire effect with no visible error.

04
Part Four
Resize Handling & Reduced Motion

Step 4 — Rebuilding on Resize, Skipping for Reduced Motion

Because the grid tracks are sized in vw/vh, every card's First and Last bounding boxes change the moment the viewport is resized — the previously captured Flip state goes stale. The fix is to wrap the whole setup in a rebuildable function stored inside a gsap.context(), so a debounced resize listener can call .revert() on the old context and recreate everything from scratch. A prefers-reduced-motion check gates the entire effect from running at all for users who've asked for less motion.

JS
let flipContext

function buildScrollFlip() {
  if (flipContext) flipContext.revert()

  flipContext = gsap.context(() => {
    // … capture states, build Flip + ScrollTrigger timeline …
    return () => gsap.set(galleryItems, { clearProps: 'all' })
  })
}

if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  buildScrollFlip()
  window.addEventListener('resize', debounce(buildScrollFlip, 200))
}
05
Part Five
Images Without Breaking the Layout

Step 5 — Loading Images Without Shifting Flip's Measurements

Grid track sizes here are defined in viewport units, not by image intrinsic dimensions, so a slow-loading photo won't itself resize a cell — but it can still arrive after ScrollTrigger has already calculated pin distances on first paint. Each image gets loading="lazy" and a plain object-fit: cover, and a single window.load listener calls ScrollTrigger.refresh() once every asset is in, so the pin math is verified against the fully-loaded page rather than an early, possibly-incomplete layout.

JS
window.addEventListener('load', () => ScrollTrigger.refresh())

None of the eight photographs are load-bearing for the layout math — they could be swapped for any other image of any aspect ratio and the grid, the Flip states, and the scroll animation would behave identically, because none of it depends on image dimensions.

Tuning Reference

ConstantDefaultEffect
bento columns / rows3 × 32.5vw / 4 × 23vhSize of the compact collage cells
final columns / rows3 × 100vw / 4 × 49.5vhFullscreen target size Flip animates toward
grid gap1vhSpacing between cards in both states
Flip easeexpoScale(1, 5)Curve used for the zoom between states
ScrollTrigger start / endcenter center / +=100%Scroll distance over which the zoom plays
resize debounce200msDelay before rebuilding Flip state after a resize

Full Source Code

The complete gallery is a single self-contained HTML document — CSS Grid, GSAP core, ScrollTrigger, and Flip loaded from a CDN, and a small IIFE tying them together. No carousel or animation framework beyond GSAP itself is required.

HTML — bento-gallery-flip.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/bento-gallery-flip.html

One HTML file, two CSS grid states, and a handful of GSAP calls. The whole zoom-on-scroll effect — bento layout, Flip-driven morphing, ScrollTrigger pinning, resize resilience, and reduced-motion support — is well under 150 lines of code.

Want to see it in action?Watch the full build on YouTube