Image Gallery

A responsive image gallery with lightbox preview, selection mode, and action buttons for download and delete.

Demo

Click any image to open the lightbox. Use arrow keys or buttons to navigate.

Mountain landscape
Ocean waves
Forest path
City skyline
Desert dunes
Lake reflection

Selection Mode Demo

Mountain landscape
Ocean waves
Forest path
City skyline
Desert dunes
Lake reflection

Basic Usage

gallery-example.tsx
import { ImageGallery } from '@stack0/elements'
const images = [
{ id: '1', src: '/images/photo1.jpg', alt: 'Photo 1' },
{ id: '2', src: '/images/photo2.jpg', alt: 'Photo 2' },
{ id: '3', src: '/images/photo3.jpg', alt: 'Photo 3' },
{ id: '4', src: '/images/photo4.jpg', alt: 'Photo 4' },
]
export function Gallery() {
return (
<ImageGallery
images={images}
columns={4}
aspect="square"
/>
)
}

Props

PropTypeDescription
imagesGalleryImage[]Array of images (required)
columns2 | 3 | 4 | 5 | "auto"Number of columns (default: 4)
aspect"square" | "video" | "auto"Image aspect ratio (default: "square")
lightboxbooleanEnable lightbox preview (default: true)
selectablebooleanEnable selection mode (default: false)
selectedstring[]Currently selected image IDs
onSelect(selected: string[]) => voidCalled when selection changes
deletablebooleanShow delete button on hover
downloadablebooleanShow download button on hover
onDelete(image: GalleryImage) => voidCalled when delete is clicked
onDownload(image: GalleryImage) => voidCalled when download is clicked

With Selection Mode

selectable-gallery.tsx
import { ImageGallery, type GalleryImage } from '@stack0/elements'
function SelectableGallery() {
const [selected, setSelected] = useState<string[]>([])
const handleDelete = async () => {
await deleteImages(selected)
setSelected([])
}
return (
<div>
{selected.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<span>{selected.length} selected</span>
<button onClick={handleDelete}>Delete</button>
<button onClick={() => setSelected([])}>Clear</button>
</div>
)}
<ImageGallery
images={images}
selectable
selected={selected}
onSelect={setSelected}
columns={4}
/>
</div>
)
}

With Actions

media-library.tsx
import { ImageGallery, type GalleryImage } from '@stack0/elements'
function MediaLibrary() {
const handleDelete = async (image: GalleryImage) => {
if (confirm(`Delete ${image.filename}?`)) {
await deleteImage(image.id)
}
}
const handleDownload = (image: GalleryImage) => {
// Trigger download
const a = document.createElement('a')
a.href = image.src
a.download = image.filename || 'image'
a.click()
}
return (
<ImageGallery
images={images}
deletable
downloadable
onDelete={handleDelete}
onDownload={handleDownload}
columns={4}
/>
)
}

With Stack0 CDN

cdn-gallery.tsx
import { ImageGallery, type GalleryImage } from '@stack0/elements'
import { Stack0 } from '@stack0/sdk'
const stack0 = new Stack0({ apiKey: process.env.STACK0_API_KEY! })
function CDNGallery() {
const [images, setImages] = useState<GalleryImage[]>([])
useEffect(() => {
async function loadImages() {
const { assets } = await stack0.cdn.list({ type: 'image' })
setImages(
assets.map((asset) => ({
id: asset.id,
src: asset.cdnUrl,
alt: asset.filename,
filename: asset.filename,
width: asset.width,
height: asset.height,
size: asset.size,
}))
)
}
loadImages()
}, [])
const handleDelete = async (image: GalleryImage) => {
await stack0.cdn.delete({ id: image.id })
setImages((prev) => prev.filter((i) => i.id !== image.id))
}
return (
<ImageGallery
images={images}
deletable
downloadable
onDelete={handleDelete}
onDownload={(img) => window.open(img.src, '_blank')}
columns={4}
emptyState={
<div className="text-center py-12 text-muted-foreground">
No images yet. Upload some to get started.
</div>
}
/>
)
}

GalleryImage Type

types.ts
interface GalleryImage {
id: string // Unique identifier
src: string // Image URL
alt?: string // Alt text
width?: number // Original width (shown in lightbox)
height?: number // Original height (shown in lightbox)
size?: number // File size in bytes (shown in lightbox)
filename?: string // Original filename (shown in lightbox)
}

Full Component Source

Copy this into your project at components/ui/image-gallery.tsx:

components/ui/image-gallery.tsx
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronLeft, ChevronRight, Download, ExternalLink, Trash2, X, ZoomIn } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
const galleryVariants = cva("grid gap-4", {
variants: {
columns: {
2: "grid-cols-2",
3: "grid-cols-2 sm:grid-cols-3",
4: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5",
auto: "grid-cols-[repeat(auto-fill,minmax(200px,1fr))]",
},
},
defaultVariants: { columns: 4 },
});
const imageVariants = cva(
"relative rounded-lg overflow-hidden bg-muted cursor-pointer group transition-transform hover:scale-[1.02]",
{
variants: {
aspect: { square: "aspect-square", video: "aspect-video", auto: "" },
},
defaultVariants: { aspect: "square" },
}
);
export interface GalleryImage {
id: string;
src: string;
alt?: string;
width?: number;
height?: number;
size?: number;
filename?: string;
}
interface ImageGalleryProps extends VariantProps<typeof galleryVariants>, VariantProps<typeof imageVariants> {
images: GalleryImage[];
lightbox?: boolean;
selectable?: boolean;
selected?: string[];
onSelect?: (selected: string[]) => void;
onDelete?: (image: GalleryImage) => void;
onDownload?: (image: GalleryImage) => void;
deletable?: boolean;
downloadable?: boolean;
emptyState?: React.ReactNode;
className?: string;
}
export function ImageGallery({
images,
lightbox = true,
selectable = false,
selected = [],
onSelect,
onDelete,
onDownload,
deletable = false,
downloadable = false,
columns,
aspect,
emptyState,
className,
}: ImageGalleryProps) {
const [lightboxOpen, setLightboxOpen] = React.useState(false);
const [lightboxIndex, setLightboxIndex] = React.useState(0);
const handleImageClick = React.useCallback(
(index: number, e: React.MouseEvent) => {
if (selectable) {
e.preventDefault();
const image = images[index];
if (!image) return;
const isSelected = selected.includes(image.id);
onSelect?.(isSelected ? selected.filter((id) => id !== image.id) : [...selected, image.id]);
} else if (lightbox) {
setLightboxIndex(index);
setLightboxOpen(true);
}
},
[images, lightbox, onSelect, selectable, selected]
);
const handlePrevious = React.useCallback(() => {
setLightboxIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
}, [images.length]);
const handleNext = React.useCallback(() => {
setLightboxIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
}, [images.length]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!lightboxOpen) return;
if (e.key === "ArrowLeft") handlePrevious();
if (e.key === "ArrowRight") handleNext();
if (e.key === "Escape") setLightboxOpen(false);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [lightboxOpen, handlePrevious, handleNext]);
React.useEffect(() => {
document.body.style.overflow = lightboxOpen ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [lightboxOpen]);
if (images.length === 0 && emptyState) return <>{emptyState}</>;
const currentImage = images[lightboxIndex];
return (
<>
<div className={cn(galleryVariants({ columns }), className)}>
{images.map((image, index) => {
const isSelected = selected.includes(image.id);
return (
<div
key={image.id}
role="button"
tabIndex={0}
onClick={(e) => handleImageClick(index, e)}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && handleImageClick(index, e as any)}
className={cn(
imageVariants({ aspect }),
isSelected && "ring-2 ring-primary ring-offset-2",
"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
)}
>
<img src={image.src} alt={image.alt || "Image"} className="h-full w-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<ZoomIn className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{selectable && (
<div className={cn(
"absolute top-2 left-2 h-5 w-5 rounded-full border-2 transition-colors",
isSelected ? "bg-primary border-primary" : "bg-white/80 border-white/80"
)}>
{isSelected && (
<svg className="h-full w-full text-primary-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
)}
{(deletable || downloadable) && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{downloadable && (
<button onClick={(e) => { e.stopPropagation(); onDownload?.(image); }}
className="h-7 w-7 rounded bg-white/90 hover:bg-white flex items-center justify-center">
<Download className="h-4 w-4" />
</button>
)}
{deletable && (
<button onClick={(e) => { e.stopPropagation(); onDelete?.(image); }}
className="h-7 w-7 rounded bg-destructive/90 hover:bg-destructive text-destructive-foreground flex items-center justify-center">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
})}
</div>
{lightbox && lightboxOpen && currentImage && (
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center" onClick={() => setLightboxOpen(false)}>
<button onClick={() => setLightboxOpen(false)}
className="absolute top-4 right-4 h-10 w-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center">
<X className="h-5 w-5 text-white" />
</button>
{images.length > 1 && (
<>
<button onClick={(e) => { e.stopPropagation(); handlePrevious(); }}
className="absolute left-4 h-10 w-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center">
<ChevronLeft className="h-5 w-5 text-white" />
</button>
<button onClick={(e) => { e.stopPropagation(); handleNext(); }}
className="absolute right-4 h-10 w-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center">
<ChevronRight className="h-5 w-5 text-white" />
</button>
</>
)}
<div className="max-w-[90vw] max-h-[85vh]" onClick={(e) => e.stopPropagation()}>
<img src={currentImage.src} alt={currentImage.alt || "Image"} className="max-w-full max-h-[85vh] object-contain" />
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4 text-white/80 text-sm">
<span>{lightboxIndex + 1} / {images.length}</span>
{currentImage.filename && <span>{currentImage.filename}</span>}
{currentImage.size && <span>{formatBytes(currentImage.size)}</span>}
{currentImage.width && currentImage.height && <span>{currentImage.width} x {currentImage.height}</span>}
<a href={currentImage.src} target="_blank" rel="noopener noreferrer" className="hover:text-white" onClick={(e) => e.stopPropagation()}>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
)}
</>
);
}