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.
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.
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.
.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;
}| Class | Grid tracks | Role |
|---|---|---|
| .gallery--bento | 3 × 32.5vw cols / 4 × 23vh rows | Default compact collage, shown to the user |
| .gallery--bento.gallery--final | 3 × 100vw cols / 4 × 49.5vh rows | Never actually shown — toggled on for one frame purely so Flip can measure it |
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.
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.
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.
const scrollTimeline = gsap.timeline({
scrollTrigger: {
trigger: galleryEl,
start: 'center center',
end: '+=100%',
scrub: true,
pin: galleryEl.parentNode
}
})
scrollTimeline.add(flipAnimation)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.
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.
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))
}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.
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
| Constant | Default | Effect |
|---|---|---|
| bento columns / rows | 3 × 32.5vw / 4 × 23vh | Size of the compact collage cells |
| final columns / rows | 3 × 100vw / 4 × 49.5vh | Fullscreen target size Flip animates toward |
| grid gap | 1vh | Spacing between cards in both states |
| Flip ease | expoScale(1, 5) | Curve used for the zoom between states |
| ScrollTrigger start / end | center center / +=100% | Scroll distance over which the zoom plays |
| resize debounce | 200ms | Delay 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.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/bento-gallery-flip.htmlOne 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.