liftbox

Quick start

Build a working React image gallery in one component: describe your slides, render a grid and open the lightbox out of the clicked thumbnail.

The lightbox is controlled. You own open and index, and it tells you when the user wants to change them. Keep it mounted next to your grid so the close animation has time to play.

Describe your slides

import type { Slide } from "liftbox";

const slides: Slide[] = [
  {
    src: "/photos/harbour-1600.jpg",
    srcSet: "/photos/harbour-640.jpg 640w, /photos/harbour-1600.jpg 1600w",
    width: 1600,
    height: 1067,
    alt: "Fishing boats in the harbour at dusk",
    title: "Harbour",
    description: "Porto, 2026",
    thumbnail: "/photos/harbour-320.jpg",
  },
  // ...
];

width and height are optional but worth passing. The image is then sized before it loads, so nothing shifts, and the open animation lands exactly.

Render the grid and the lightbox

import { useRef, useState } from "react";
import { Lightbox } from "liftbox";
import "liftbox/styles.css";

export function Gallery() {
  const [index, setIndex] = useState(-1);
  const thumbnails = useRef<(HTMLButtonElement | null)[]>([]);

  return (
    <>
      {slides.map((slide, i) => (
        <button
          key={slide.src}
          ref={(el) => {
            thumbnails.current[i] = el;
          }}
          onClick={() => setIndex(i)}
          aria-label={`Open ${slide.alt}`}
        >
          <img src={slide.thumbnail} alt="" width={200} height={133} />
        </button>
      ))}

      <Lightbox
        slides={slides}
        open={index >= 0}
        index={Math.max(index, 0)}
        onIndexChange={setIndex}
        onClose={() => setIndex(-1)}
        getThumbnail={(i) => thumbnails.current[i]}
      />
    </>
  );
}

Try it

Live demo. Click a thumbnail to open it.

Why getThumbnail

It returns the element that opened a slide. The lightbox flies the image out of it, flies it back to the thumbnail of the slide on screen when closing, and returns focus there. Without it, the image zooms and fades in, and focus goes back to whatever was focused before opening.

Using index = -1 for closed

Keeping one number for both state values avoids a second useState. -1 means closed, so open={index >= 0}, and Math.max(index, 0) keeps the last valid index during the close animation. Deep linking uses the same shape, so switching to it later is a one-line change.

Edit this page on GitHub

On this page