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.
Selection Mode Demo
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 (<ImageGalleryimages={images}columns={4}aspect="square"/>)}
Props
| Prop | Type | Description |
|---|---|---|
| images | GalleryImage[] | Array of images (required) |
| columns | 2 | 3 | 4 | 5 | "auto" | Number of columns (default: 4) |
| aspect | "square" | "video" | "auto" | Image aspect ratio (default: "square") |
| lightbox | boolean | Enable lightbox preview (default: true) |
| selectable | boolean | Enable selection mode (default: false) |
| selected | string[] | Currently selected image IDs |
| onSelect | (selected: string[]) => void | Called when selection changes |
| deletable | boolean | Show delete button on hover |
| downloadable | boolean | Show download button on hover |
| onDelete | (image: GalleryImage) => void | Called when delete is clicked |
| onDownload | (image: GalleryImage) => void | Called 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>)}<ImageGalleryimages={images}selectableselected={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 downloadconst a = document.createElement('a')a.href = image.srca.download = image.filename || 'image'a.click()}return (<ImageGalleryimages={images}deletabledownloadableonDelete={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 (<ImageGalleryimages={images}deletabledownloadableonDelete={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 identifiersrc: string // Image URLalt?: string // Alt textwidth?: 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 (<divkey={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>)}</>);}