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

Features
- Swipe, flick, arrow-key, and thumbnail-strip navigation, with
slide,fade, andzoomtransitions 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
srcSetand<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 liftboxImport 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
| Component | Main configuration |
|---|---|
MasonryGrid | Responsive columns and gap for staggered photo columns. |
JustifiedGrid | Responsive rowHeight, gap, and lastRow behavior for filled rows. |
UniformGrid | columns and aspectRatio for equal-size cropped tiles. |
GalleryFilter | categories, 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
| Prop | Purpose |
|---|---|
slides | Required array of Slide objects. |
open / onClose | Required controlled visibility and close callback. |
index / onIndexChange | Current slide index and slide-change callback. |
getThumbnail | Returns the gallery element used for the opening and closing animation. |
transition | slide (default), fade, or zoom when changing slides. |
loop / swipe / keyboard | Control wrapping, gestures, and keyboard navigation; enabled by default. |
zoom / thumbnails / fullscreen | Viewer controls; enabled by default. zoom accepts an options object. |
slideshow / pager | Optional autoplay controls and pager dots; disabled by default. |
video | Video options such as { autoplay: true }; autoplay is off by default. |
rotate / flip / download / share | Additional toolbar actions; disabled by default. |
captionPosition | bar (default) or slide for image captions. |
preload | Neighboring slides mounted on each side; default 2. |
labels / icons / render | Customize labels and icons, or override slides, captions, and toolbars through render.slide, render.caption, and render.toolbar. |
classNames / style | Add 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.
| Property | Purpose |
|---|---|
src | Image URL, video URL, or embed URL; required. |
thumbnail | Small preview for gallery tiles, the thumbnail strip, and loading placeholders. |
width / height | Natural media dimensions for sizing and transitions. |
alt / title / description | Accessible image description and displayed caption content. |
srcSet / sizes / sources | Responsive images, <picture> sources, or alternative native video formats. |
type / poster / tracks | Explicit media type, video still image, and caption tracks. |
download / downloadName | Per-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.




