Lightbox Setup
A GSAP Flip-powered lightbox gallery. Clicking a grid image triggers a seamless FLIP animation that moves the image from the grid into a full-screen overlay, with background fade, nav controls, counter, keyboard navigation, and click-outside-to-close. Supports multiple independent gallery groups per page and optional lifecycle callbacks.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/Flip.min.js"></script>Code
<div data-gallery="" class="gallery-group">
<div role="list" class="gallery-grid">
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add146391123833e91d869_image-2.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add146d05ce012e9b59eb3_image-9.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add14685ff33bc9a1bca6f_image-3.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add1469f78a3b9edcab7d6_image-10.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add14675fda0c86dd933fd_image-4.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add1464b2c93225d824618_image-7.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add146a4e4e7d3b06da7e8_image-8.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add1467403f8fe57d124fb_image-6.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
<div data-lightbox="trigger-parent" role="listitem" class="gallery-grid__item"><button data-lightbox="trigger" class="gallery-item__button"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add146018bbc6fb21e8856_image-5.avif" loading="lazy" alt="" class="gallery-item__img"></button></div>
</div>
<div aria-modal="true" data-lightbox="wrapper" role="dialog" class="lightbox-wrap">
<div class="lightbox-img__wrap">
<div class="lightbox-img__list">
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add146391123833e91d869_image-2.avif" loading="lazy" alt="" class="lightbox-img"></div>
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add146d05ce012e9b59eb3_image-9.avif" loading="lazy" alt="" class="lightbox-img"></div>
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add14685ff33bc9a1bca6f_image-3.avif" loading="lazy" alt="" class="gallery-item__img"></div>
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add1469f78a3b9edcab7d6_image-10.avif" loading="lazy" alt="" class="lightbox-img"></div>
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add14675fda0c86dd933fd_image-4.avif" loading="lazy" alt="" class="lightbox-img"></div>
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add1464b2c93225d824618_image-7.avif" loading="lazy" alt="" class="lightbox-img"></div>
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add146a4e4e7d3b06da7e8_image-8.avif" loading="lazy" alt="" class="lightbox-img"></div>
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add1467403f8fe57d124fb_image-6.avif" loading="lazy" alt="" class="lightbox-img"></div>
<div data-lightbox="item" class="lightbox-img__item"><img src="https://cdn.prod.website-files.com/67adb90c0e4e191daea5f4ed/67add146018bbc6fb21e8856_image-5.avif" loading="lazy" alt="" class="lightbox-img"></div>
</div>
</div>
<div class="lightbox-nav">
<div data-lightbox="nav" class="lightbox-nav__col start">
<p class="lightbox-nav__text"><span data-lightbox="counter-current">9</span> / <span data-lightbox="counter-total">9</span></p>
</div>
<div data-lightbox="nav" class="lightbox-nav__col center">
<button data-lightbox="prev" class="lightbox-nav__button">
<div class="lightbox-nav__dot"></div>
<span class="lightbox-nav__text">prev</span>
</button>
<button data-lightbox="next" class="lightbox-nav__button">
<span class="lightbox-nav__text">next</span>
<div class="lightbox-nav__dot"></div>
</button>
</div>
<div data-lightbox="nav" class="lightbox-nav__col end"><button data-lightbox="close" class="lightbox-nav__button"><span class="lightbox-nav__text">close</span></button></div>
</div>
</div>
</div>.gallery-grid {
grid-column-gap: 1.25em;
grid-row-gap: 4em;
flex-flow: wrap;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
padding-bottom: 8em;
display: flex;
}
.gallery-grid__item {
width: calc(33.3333% - .833333em);
}
.gallery-item__button {
outline-offset: -1px;
background-color: #0000;
border: 1px #000;
border-radius: .375em;
outline: 1px #131313;
width: 100%;
padding: 0;
}
.gallery-item__button:focus-visible {
outline-offset: 3px;
border-radius: .25em;
outline: 1px solid #131313;
}
.gallery-item__img {
border-radius: .375em;
width: 100%;
}
.lightbox-wrap {
z-index: 100;
justify-content: center;
align-items: center;
width: 100%;
height: 100dvh;
display: none;
position: fixed;
inset: 0% 0% auto;
}
.lightbox-wrap.is-active {
display: flex;
}
.lightbox-img__wrap {
width: 90vw;
height: calc(100svh - 10em);
}
.lightbox-img__container {
width: 100%;
height: 100%;
}
.lightbox-img__list {
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: relative;
}
.lightbox-img__item {
visibility: hidden;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: absolute;
}
.lightbox-img__item.is-active {
visibility: visible;
}
.lightbox-img {
object-fit: contain;
border-radius: .375em;
min-width: auto;
max-height: 100%;
}
.lightbox-img__item img {
object-fit: contain !important;
min-width: auto;
width: auto;
max-height: 100%;
}
.lightbox-nav {
z-index: 2;
color: #fff;
justify-content: space-between;
align-items: center;
display: flex;
position: absolute;
bottom: 2em;
left: 2em;
right: 2em;
}
.lightbox-nav__col {
width: 33.333%;
}
.lightbox-nav__col.start {
justify-content: flex-start;
align-items: center;
display: flex;
}
.lightbox-nav__col.center {
grid-column-gap: 2em;
grid-row-gap: 2em;
justify-content: center;
align-items: center;
display: flex;
}
.lightbox-nav__col.end {
justify-content: flex-end;
align-items: center;
display: flex;
}
.lightbox-nav__text {
margin-bottom: 0;
font-size: 1em;
}
.lightbox-nav__button {
grid-column-gap: .5em;
grid-row-gap: .5em;
background-color: #0000;
justify-content: flex-start;
align-items: center;
margin: -1em;
padding: 1em;
display: flex;
}
.lightbox-nav__dot {
background-color: currentColor;
border-radius: 10em;
width: .375em;
height: .375em;
margin-bottom: -.1em;
transition-property: transform;
transition-duration: .45s;
transition-timing-function: cubic-bezier(.625, .05, 0, 1);
}
@media screen and (max-width: 767px) {
.gallery-grid {
grid-column-gap: 1em;
}
.gallery-grid__item {
width: calc(50% - .5em);
}
}
@media screen and (max-width: 479px) {
.gallery-grid {
grid-column-gap: .75em;
grid-row-gap: 3em;
}
.gallery-grid__item {
width: calc(50% - .375em);
}
}gsap.registerPlugin(Flip)
gsap.defaults({
ease: "power4.inOut",
duration: 0.8,
});
function createLightbox(container, {
onStart,
onOpen,
onClose,
onCloseComplete
} = {}) {
const elements = {
wrapper: container.querySelector('[data-lightbox="wrapper"]'),
triggers: container.querySelectorAll('[data-lightbox="trigger"]'),
triggerParents: container.querySelectorAll('[data-lightbox="trigger-parent"]'),
items: container.querySelectorAll('[data-lightbox="item"]'),
nav: container.querySelectorAll('[data-lightbox="nav"]'),
counter: {
current: container.querySelector('[data-lightbox="counter-current"]'),
total: container.querySelector('[data-lightbox="counter-total"]')
},
buttons: {
prev: container.querySelector('[data-lightbox="prev"]'),
next: container.querySelector('[data-lightbox="next"]'),
close: container.querySelector('[data-lightbox="close"]')
}
};
// Create our main timeline that will coordinate all animations
const mainTimeline = gsap.timeline();
// ————————— COUNTER ————————— //
if (elements.counter.total) {
elements.counter.total.textContent = elements.triggers.length;
}
// ————————— CLOSE FUNCTION ————————— //
function closeLightbox() {
// on close callback
onClose?.();
// First, we clear any running animations to prevent conflicts
mainTimeline.clear();
gsap.killTweensOf([
elements.wrapper,
elements.nav,
elements.triggerParents,
elements.items,
container.querySelector('[data-lightbox="original"]')
]);
const tl = gsap.timeline({
defaults: { ease: "power2.inOut" },
onComplete: () => {
elements.wrapper.classList.remove('is-active');
// Show all hidden images in lightbox items
elements.items.forEach(item => {
item.classList.remove('is-active');
const lightboxImage = item.querySelector('img');
if (lightboxImage) {
lightboxImage.style.display = '';
}
});
// Clear any lingering transform properties on the original image
const originalImg = container.querySelector('[data-lightbox="original"]');
if (originalImg) { gsap.set(originalImg, { clearProps: "all" });}
// Remove the fixed height from the trigger parent
const originalParent = container.querySelector('[data-lightbox="original-parent"]');
if (originalParent) { originalParent.parentElement.style.removeProperty('height'); }
// on close complete callback
onCloseComplete?.();
}
});
// First, find and move back the original item
const originalItem = container.querySelector('[data-lightbox="original"]');
const originalParent = container.querySelector('[data-lightbox="original-parent"]');
if (originalItem && originalParent) {
// Before moving the item back, clear its transforms
gsap.set(originalItem, { clearProps: "all" });
// Move the item back to its original parent
originalParent.appendChild(originalItem);
originalParent.removeAttribute('data-lightbox');
originalItem.removeAttribute('data-lightbox');
}
// Find active slide
let activeLightboxSlide = container.querySelector('[data-lightbox="item"].is-active')
// Return animation
tl.to(elements.triggerParents, {
autoAlpha: 1,
duration: 0.5,
stagger: 0.03,
overwrite: true
})
.to(elements.nav, {
autoAlpha: 0,
y: "1rem",
duration: 0.4,
stagger: 0
},"<")
.to(elements.wrapper, {
backgroundColor: "rgba(0,0,0,0)",
duration: 0.4
}, "<")
.to(activeLightboxSlide,{
autoAlpha:0,
duration: 0.4,
},"<")
.set([elements.items, activeLightboxSlide, elements.triggerParents], { clearProps: "all" })
// Add this timeline to our main timeline
mainTimeline.add(tl);
}
// ————————— CLICK-OUTSIDE FUNCTIONALITY ————————— //
function handleOutsideClick(event) {
if (event.detail === 0) {
return;
}
const clickedElement = event.target;
const isOutside = !clickedElement.closest('[data-lightbox="item"].is-active img, [data-lightbox="nav"], [data-lightbox="close"], [data-lightbox="trigger"]');
if (isOutside) {
closeLightbox();
}
}
// ————————— TOGGLE ACTIVE ITEM IN LIGHTBOX ————————— //
function updateActiveItem(index) {
elements.items.forEach(item => item.classList.remove('is-active'));
elements.items[index].classList.add('is-active');
if (elements.counter.current) {
elements.counter.current.textContent = index + 1;
}
}
// ————————— CLICK TO OPEN ————————— //
elements.triggers.forEach((trigger, index) => {
trigger.addEventListener('click', () => {
// On start of open callback
onStart?.();
// Clear any running animations before starting new ones
mainTimeline.clear();
gsap.killTweensOf([
elements.wrapper,
elements.nav,
elements.triggerParents
]);
const img = trigger.querySelector("img")
const state = Flip.getState(img);
// Store the trigger's current height before the FLIP animation
// So the grid does not collapse
const triggerRect = trigger.getBoundingClientRect();
trigger.parentElement.style.height = `${triggerRect.height}px`;
// Save element and parent that was clicked
trigger.setAttribute('data-lightbox', 'original-parent');
img.setAttribute('data-lightbox', 'original');
// Set correct lightbox item to visible
updateActiveItem(index);
// Start listening for clicks outside of lightbox
container.addEventListener('click', handleOutsideClick);
const tl = gsap.timeline({
onComplete: () => {
// On open callback
onOpen?.();
}
});
elements.wrapper.classList.add('is-active');
const targetItem = elements.items[index];
// Hide the original image in the lightbox item
const lightboxImage = targetItem.querySelector('img');
if (lightboxImage) {
lightboxImage.style.display = 'none';
}
// Fade out other grid items
elements.triggerParents.forEach(otherTrigger => {
if (otherTrigger !== trigger) {
gsap.to(otherTrigger, {
autoAlpha: 0,
duration: 0.4,
stagger:0.02,
overwrite: true
});
}
});
// Flip clicked image into lightbox
if (!targetItem.contains(img)) {
targetItem.appendChild(img);
tl.add(
Flip.from(state, {
targets: img,
absolute: true,
duration: 0.6,
ease: "power2.inOut"
}), 0
);
}
// Animate in our navigation and background
tl.to(elements.wrapper, {
backgroundColor: "rgba(0,0,0,0.75)",
duration: 0.6
}, 0)
.fromTo(elements.nav, {
autoAlpha: 0,
y: "1rem"
}, {
autoAlpha: 1,
y: "0rem",
duration: 0.6,
stagger: { each: 0.05, from: "center" }
}, 0.2);
// Add this timeline to our main timeline
mainTimeline.add(tl);
});
});
// ————————— NAV BUTTONS ————————— //
if (elements.buttons.next) {
elements.buttons.next.addEventListener('click', () => {
const currentIndex = Array.from(elements.items).findIndex(item =>
item.classList.contains('is-active')
);
const nextIndex = (currentIndex + 1) % elements.items.length;
updateActiveItem(nextIndex);
});
}
if (elements.buttons.prev) {
elements.buttons.prev.addEventListener('click', () => {
const currentIndex = Array.from(elements.items).findIndex(item =>
item.classList.contains('is-active')
);
const prevIndex = (currentIndex - 1 + elements.items.length) % elements.items.length;
updateActiveItem(prevIndex);
});
}
if (elements.buttons.close) {
elements.buttons.close.addEventListener('click', closeLightbox);
}
// ————————— KEYBOARD NAV ————————— //
document.addEventListener('keydown', (event) => {
if (!elements.wrapper.classList.contains('is-active')) return;
switch (event.key) {
case 'Escape':
closeLightbox();
break;
case 'ArrowRight':
elements.buttons.next?.click();
break;
case 'ArrowLeft':
elements.buttons.prev?.click();
break;
}
});
}
document.addEventListener("DOMContentLoaded", () =>{
let wrappers = document.querySelectorAll("[data-gallery]")
wrappers.forEach((wrapper) => {
// SIMPLE INIT
createLightbox(wrapper)
// SUPPORTED CALLBACKS:
// createLightbox(wrapper, {
// onStart: () => console.log("Starting"),
// onOpen: () => console.log("Open"),
// onClose: () => console.log("Closing"),
// onCloseComplete: () => console.log("Done")
// });
});
})Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| [data-gallery] | attribute | — | The outermost container for one gallery group. The script queries this element to scope all selectors — add it to a wrapper div that contains both the image grid and the lightbox overlay. |
| [data-lightbox="wrapper"] | attribute | — | The fixed full-screen lightbox overlay element. Starts hidden (display: none) and receives the is-active class when opened. |
| [data-lightbox="trigger"] | attribute | — | The clickable element inside each grid item — typically a button wrapping the thumbnail image. Clicking this opens the lightbox and triggers the FLIP animation. |
| [data-lightbox="trigger-parent"] | attribute | — | The parent wrapper of each trigger in the grid. Other trigger parents are faded out when the lightbox opens, and restored when it closes. |
| [data-lightbox="item"] | attribute | — | Each image slot inside the lightbox overlay. Must appear in the same order as the grid triggers. Receives is-active when its corresponding image is being viewed. |
| [data-lightbox="nav"] | attribute | — | Any navigation element (prev/next/close bar, counter bar). All elements with this attribute are faded in on open and faded out on close. |
| [data-lightbox="prev"] | attribute | — | Button that navigates to the previous image in the lightbox. |
| [data-lightbox="next"] | attribute | — | Button that navigates to the next image in the lightbox. |
| [data-lightbox="close"] | attribute | — | Button that closes the lightbox and runs the return FLIP animation. |
| [data-lightbox="counter-current"] | attribute | — | Element whose text content is updated to show the current image index (1-based). |
| [data-lightbox="counter-total"] | attribute | — | Element whose text content is set to the total number of images on init. |
Notes
- •Requires GSAP and the Flip plugin loaded via CDN before the script runs.
- •The image list inside the lightbox wrapper must have the same number of items and be in the same order as the grid triggers.
- •Grid layout does not collapse during the FLIP animation — the script fixes the trigger parent height before moving the img element out of the DOM.
- •Keyboard navigation is supported: Escape closes, ArrowRight goes next, ArrowLeft goes prev.
- •Clicking outside the active image (anywhere on the overlay background) also closes the lightbox.
Guide
How it works
The script captures the DOM state of the clicked thumbnail with GSAP Flip.getState(), moves the img element into the active lightbox item slot, then animates from the captured state to its new position. This creates a seamless expansion effect without any CSS transitions or cloned elements.
Multiple gallery groups
Add as many [data-gallery] containers as you need on the page. Each one is fully independent — clicking an image in one group only affects that group's overlay and grid.
Lifecycle callbacks
Pass an options object to createLightbox() to hook into four lifecycle events. Useful for pausing smooth scroll libraries (e.g. Lenis) while the lightbox is open.
createLightbox(wrapper, {
onStart: () => lenis.stop(),
onOpen: () => console.log("Open"),
onClose: () => lenis.start(),
onCloseComplete: () => console.log("Done")
});Webflow CMS integration
Place a [data-gallery] div on the page. Add a CMS List Wrapper inside for the grid — set [data-lightbox="trigger-parent"] on each list item, add a button with [data-lightbox="trigger"] inside each item, and place the image inside that button. Then add a second CMS List (same collection) inside the [data-lightbox="wrapper"] fixed overlay, with [data-lightbox="item"] on each list item.
Overflow hidden warning
Do not apply overflow: hidden to the trigger, trigger-parent, or lightbox item elements. It will clip the GSAP Flip animation mid-transition and produce a jarring visual glitch.