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.