Skip to main content
Back to Blog
Tutorials
2 min read
November 28, 2024

How to Build a Portfolio Gallery with Lightbox in React

Build a responsive portfolio gallery with filtering, masonry layout, and fullscreen lightbox navigation in React.

Ryel Banfield

Founder & Lead Developer

A portfolio gallery showcases your work beautifully. Here is how to build one with filtering and a lightbox.

Step 1: Gallery Data

export interface GalleryItem {
  id: string;
  src: string;
  alt: string;
  category: string;
  title: string;
  description?: string;
  width: number;
  height: number;
}

export const portfolioItems: GalleryItem[] = [
  { id: "1", src: "/portfolio/project-1.jpg", alt: "E-commerce redesign", category: "Web Design", title: "E-commerce Redesign", width: 1200, height: 800 },
  { id: "2", src: "/portfolio/project-2.jpg", alt: "Mobile banking app", category: "Mobile App", title: "Banking App", width: 800, height: 1200 },
  // ... more items
];

Step 2: Filterable Gallery

"use client";

import { useState, useMemo } from "react";
import Image from "next/image";
import type { GalleryItem } from "@/lib/portfolio";

export function PortfolioGallery({ items }: { items: GalleryItem[] }) {
  const [activeCategory, setActiveCategory] = useState("All");
  const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);

  const categories = useMemo(
    () => ["All", ...new Set(items.map((item) => item.category))],
    [items]
  );

  const filtered = useMemo(
    () => activeCategory === "All"
      ? items
      : items.filter((item) => item.category === activeCategory),
    [items, activeCategory]
  );

  return (
    <div>
      {/* Category Filters */}
      <div className="mb-8 flex flex-wrap justify-center gap-2">
        {categories.map((cat) => (
          <button
            key={cat}
            onClick={() => setActiveCategory(cat)}
            className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
              activeCategory === cat
                ? "bg-blue-600 text-white"
                : "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
            }`}
          >
            {cat}
          </button>
        ))}
      </div>

      {/* Masonry Grid */}
      <div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
        {filtered.map((item, index) => (
          <div
            key={item.id}
            className="mb-4 break-inside-avoid cursor-pointer overflow-hidden rounded-xl"
            onClick={() => setLightboxIndex(index)}
          >
            <div className="group relative">
              <Image
                src={item.src}
                alt={item.alt}
                width={item.width}
                height={item.height}
                className="w-full transition-transform duration-300 group-hover:scale-105"
              />
              <div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
                <div className="p-4">
                  <p className="text-sm font-semibold text-white">{item.title}</p>
                  <p className="text-xs text-gray-300">{item.category}</p>
                </div>
              </div>
            </div>
          </div>
        ))}
      </div>

      {/* Lightbox */}
      {lightboxIndex !== null && (
        <Lightbox
          items={filtered}
          currentIndex={lightboxIndex}
          onClose={() => setLightboxIndex(null)}
          onNavigate={setLightboxIndex}
        />
      )}
    </div>
  );
}

Step 3: Lightbox Component

"use client";

import { useEffect, useCallback } from "react";
import Image from "next/image";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
import type { GalleryItem } from "@/lib/portfolio";

export function Lightbox({
  items,
  currentIndex,
  onClose,
  onNavigate,
}: {
  items: GalleryItem[];
  currentIndex: number;
  onClose: () => void;
  onNavigate: (index: number) => void;
}) {
  const item = items[currentIndex];

  const prev = useCallback(() => {
    onNavigate(currentIndex > 0 ? currentIndex - 1 : items.length - 1);
  }, [currentIndex, items.length, onNavigate]);

  const next = useCallback(() => {
    onNavigate(currentIndex < items.length - 1 ? currentIndex + 1 : 0);
  }, [currentIndex, items.length, onNavigate]);

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "Escape") onClose();
      if (e.key === "ArrowLeft") prev();
      if (e.key === "ArrowRight") next();
    }
    document.addEventListener("keydown", handleKeyDown);
    document.body.style.overflow = "hidden";
    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      document.body.style.overflow = "";
    };
  }, [onClose, prev, next]);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90">
      {/* Close button */}
      <button
        onClick={onClose}
        className="absolute right-4 top-4 z-10 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
      >
        <X className="h-6 w-6" />
      </button>

      {/* Navigation */}
      <button
        onClick={prev}
        className="absolute left-4 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
      >
        <ChevronLeft className="h-6 w-6" />
      </button>
      <button
        onClick={next}
        className="absolute right-4 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
      >
        <ChevronRight className="h-6 w-6" />
      </button>

      {/* Image */}
      <div className="relative max-h-[85vh] max-w-[90vw]">
        <Image
          src={item.src}
          alt={item.alt}
          width={item.width}
          height={item.height}
          className="max-h-[85vh] w-auto object-contain"
          priority
        />
      </div>

      {/* Info bar */}
      <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 p-6">
        <p className="text-lg font-semibold text-white">{item.title}</p>
        {item.description && (
          <p className="mt-1 text-sm text-gray-300">{item.description}</p>
        )}
        <p className="mt-2 text-xs text-gray-400">
          {currentIndex + 1} / {items.length}
        </p>
      </div>
    </div>
  );
}

Need a Portfolio Website?

We design and build stunning portfolio websites that showcase your work. Contact us to discuss your project.

portfoliogallerylightboximagesReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles