React Lightbox & Gallery with Thumbnail Animations – liftbox

Description:

liftbox is a React lightbox and gallery library that expands photos from their clicked thumbnails, including cropped previews, and animates them back when the viewer closes.

You can attach it to an existing gallery or use its masonry, justified, and uniform photo grids, with image zoom and video playback built into the viewer.

Preview

lightbox-gallery-liftbox

Features

  • Swipe, flick, arrow-key, and thumbnail-strip navigation, with slide, fade, and zoom transitions between slides.
  • Pinch, mouse-wheel, double-tap, and toolbar-button image zoom, plus pinch-to-close gestures.
  • Native video playback, YouTube and Vimeo embeds, and custom iframe slides.
  • Optional slideshows, pager dots, fullscreen mode, rotation, flip, sharing, and downloads.
  • Responsive images through srcSet and <picture> sources for AVIF, WebP, and art-directed crops.
  • URL-based photo links through useLightboxHash, with browser Back navigation.
  • Replaceable labels, icons, and rendered content, plus CSS variables for colors, dimensions, and animation timing.
  • Focus trapping and return, keyboard controls, reduced-motion handling, and right-to-left layouts.

Use Cases

  • A photography portfolio where visitors open cropped thumbnails, swipe between full-size photos, and close back to the active image.
  • A product gallery with touch navigation, image zoom, and downloadable high-resolution photographs.
  • An editorial gallery that presents photographs and videos together, with shareable links to individual slides.

How To Use It

Install the package in your React project.

npm install liftbox

Import the core stylesheet once in your app entry file or root layout:

import "liftbox/styles.css";

Basic Usage

Create PhotoGallery.tsx. Keep Lightbox mounted during the closing animation and pass getThumbnail the corresponding thumbnail button. That element marks the photo’s opening position and where it returns on close. A thumbnail that has scrolled out of view triggers the zoom-and-fade fallback.

import { useRef, useState } from "react";
import { Lightbox, type Slide } from "liftbox";
const photos: Slide[] = [
  {
    src: "/photos/mountain-lake.jpg",
    thumbnail: "/photos/mountain-lake-thumb.jpg",
    width: 1600,
    height: 1067,
    alt: "Mountain lake at sunrise",
    title: "Morning at the lake",
  },
  {
    src: "/photos/coastal-trail.jpg",
    thumbnail: "/photos/coastal-trail-thumb.jpg",
    width: 1600,
    height: 1067,
    alt: "Walking trail above a rocky coastline",
    title: "Coastal trail",
  },
];
export function PhotoGallery() {
  const [activeIndex, setActiveIndex] = useState(-1);
  const thumbnails = useRef<(HTMLButtonElement | null)[]>([]);
  return (
    <>
      <div className="photo-grid">
        {photos.map((photo, index) => (
          <button
            key={photo.src}
            type="button"
            ref={(element) => {
              thumbnails.current[index] = element;
            }}
            onClick={() => setActiveIndex(index)}
            aria-label={`Open ${photo.alt}`}
          >
            <img src={photo.thumbnail} alt="" width={240} height={160} />
          </button>
        ))}
      </div>
      <Lightbox
        slides={photos}
        open={activeIndex >= 0}
        index={Math.max(activeIndex, 0)}
        onIndexChange={setActiveIndex}
        onClose={() => setActiveIndex(-1)}
        getThumbnail={(index) => thumbnails.current[index]}
      />
    </>
  );
}

Add these styles to your gallery CSS to crop thumbnails into consistent tiles:

.photo-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 12px;
}
.photo-grid button {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  padding: 0;
  border: 0;
  overflow: hidden;
  cursor: zoom-in;
}
.photo-grid img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Gallery Layouts and Filtering

ComponentMain configuration
MasonryGridResponsive columns and gap for staggered photo columns.
JustifiedGridResponsive rowHeight, gap, and lastRow behavior for filled rows.
UniformGridcolumns and aspectRatio for equal-size cropped tiles.
GalleryFiltercategories, value, onChange, and count for category buttons.

Masonry Gallery with Category Filters

Set each photo’s width and height for masonry positioning. Pass the filtered photo array to both the grid and the lightbox to keep thumbnail and slide indexes aligned when visitors switch categories.

import { useRef, useState } from "react";
import {
  GalleryFilter,
  Lightbox,
  MasonryGrid,
  type GridHandle,
  type Slide,
} from "liftbox";
type PortfolioPhoto = Slide & {
  category: "landscapes" | "architecture";
};
export function PortfolioGallery({ photos }: { photos: PortfolioPhoto[] }) {
  const [category, setCategory] = useState<PortfolioPhoto["category"] | null>(null);
  const [activeIndex, setActiveIndex] = useState(-1);
  const gridRef = useRef<GridHandle>(null);
  const visiblePhotos = category
    ? photos.filter((photo) => photo.category === category)
    : photos;
  return (
    <>
      <GalleryFilter
        categories={["landscapes", "architecture"]}
        value={category}
        onChange={(nextCategory) => {
          setActiveIndex(-1);
          setCategory(nextCategory);
        }}
        count={visiblePhotos.length}
      />
      <MasonryGrid
        ref={gridRef}
        slides={visiblePhotos}
        columns={(width) => (width < 700 ? 2 : 4)}
        gap={12}
        onOpen={setActiveIndex}
      />
      <Lightbox
        slides={visiblePhotos}
        open={activeIndex >= 0}
        index={Math.max(activeIndex, 0)}
        onIndexChange={setActiveIndex}
        onClose={() => setActiveIndex(-1)}
        getThumbnail={(index) => gridRef.current?.getThumbnail(index)}
      />
    </>
  );
}

Link Directly to a Photo

In PhotoGallery.tsx, replace the useState(-1) declaration with useLightboxHash. Keep the existing Lightbox props and thumbnail click handlers.

import { useLightboxHash } from "liftbox";
const [activeIndex, setActiveIndex] = useLightboxHash("photo", {
  count: photos.length,
});

The second photo opens at #photo-2. Add names with one unique slug per slide to create URLs such as #photo-coastal-trail. Opening the lightbox adds a history entry, and changing photos updates that entry.

Lightbox Props

PropPurpose
slidesRequired array of Slide objects.
open / onCloseRequired controlled visibility and close callback.
index / onIndexChangeCurrent slide index and slide-change callback.
getThumbnailReturns the gallery element used for the opening and closing animation.
transitionslide (default), fade, or zoom when changing slides.
loop / swipe / keyboardControl wrapping, gestures, and keyboard navigation; enabled by default.
zoom / thumbnails / fullscreenViewer controls; enabled by default. zoom accepts an options object.
slideshow / pagerOptional autoplay controls and pager dots; disabled by default.
videoVideo options such as { autoplay: true }; autoplay is off by default.
rotate / flip / download / shareAdditional toolbar actions; disabled by default.
captionPositionbar (default) or slide for image captions.
preloadNeighboring slides mounted on each side; default 2.
labels / icons / renderCustomize labels and icons, or override slides, captions, and toolbars through render.slide, render.caption, and render.toolbar.
classNames / styleAdd CSS classes and inline style values.

Slide Data

Use type: "iframe" for arbitrary embedded pages. Native video extensions and YouTube or Vimeo URLs are detected from src.

PropertyPurpose
srcImage URL, video URL, or embed URL; required.
thumbnailSmall preview for gallery tiles, the thumbnail strip, and loading placeholders.
width / heightNatural media dimensions for sizing and transitions.
alt / title / descriptionAccessible image description and displayed caption content.
srcSet / sizes / sourcesResponsive images, <picture> sources, or alternative native video formats.
type / poster / tracksExplicit media type, video still image, and caption tracks.
download / downloadNamePer-slide download URL, visibility, and filename.

Styling and Customization

Override the lightbox’s --eg-* CSS variables after importing the package stylesheet.

.eg-root {
  --eg-background: #111827;
  --eg-color: #f9fafb;
  --eg-focus-color: #fbbf24;
  --eg-arrow-background: rgb(255 255 255 / 0.16);
  --eg-radius: 12px;
  --eg-duration: 380ms;
}

Next.js App Router

Place the gallery in a Client Component with "use client", and import liftbox/styles.css from app/layout.tsx. A Server Component can fetch the photo data and pass the slide objects to the gallery. The lightbox bundle includes a client boundary and does not require ssr: false. For a server-rendered built-in grid, set defaultContainerWidth when its initial width differs from the gallery container.

FAQs

Q: Why do cross-origin downloads sometimes open in a new tab?
A: The download button fetches files from other origins with CORS to preserve the filename. If that request fails or the server disallows it, liftbox opens the file in a new tab. Enable downloads with download and use downloadName for a slide-specific filename.

Q: Why can a server-rendered grid lose its layout under a strict Content Security Policy?
A: The built-in grids use inline style attributes for item positions in server-rendered HTML. A policy that blocks inline styles prevents those positions from applying until the client renders the grid. Render the grid on the client or configure CSP for those attributes.

Q: Will a photo hash link display that image in social previews?
A: Social preview crawlers ignore the hash. Use a dedicated URL with its own og:image for photo-specific previews. useLightboxHash still opens the requested slide for visitors who follow the link.

Alternatives and Related Resources

Add Comment