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.

gsapfliplightboxgalleryanimationmodal

Setup — External Scripts

CDN — GSAP + Flip (add before </body>)
html
<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

HTML
html
<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>
CSS
css
.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);
  }
}
JavaScript
javascript
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

NameTypeDefaultDescription
[data-gallery]attributeThe 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"]attributeThe fixed full-screen lightbox overlay element. Starts hidden (display: none) and receives the is-active class when opened.
[data-lightbox="trigger"]attributeThe 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"]attributeThe 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"]attributeEach 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"]attributeAny 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"]attributeButton that navigates to the previous image in the lightbox.
[data-lightbox="next"]attributeButton that navigates to the next image in the lightbox.
[data-lightbox="close"]attributeButton that closes the lightbox and runs the return FLIP animation.
[data-lightbox="counter-current"]attributeElement whose text content is updated to show the current image index (1-based).
[data-lightbox="counter-total"]attributeElement 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.