Componentdata1.57 KB gzipZero dependencies
Gallery Grid
Image gallery grid with fullscreen lightbox and keyboard paging.
Preview
Live Preview
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add gallery-gridOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { GalleryGrid } from "@/components/ui/gallery-grid";
<GalleryGrid />Usage guidance
Use Gallery Grid when a ticket stub surface needs Image gallery grid with fullscreen lightbox and keyboard paging. Keep props minimal and prefer composition with sibling stubs.
Avoid Gallery Grid for long form flows or dense data grids where a dedicated layout block fits better.
Source code
gallery-grid.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
export interface GalleryImage {
id: string;
src: string;
alt?: string;
}
export interface GalleryGridProps extends React.HTMLAttributes<HTMLDivElement> {
images: GalleryImage[];
columns?: 2 | 3 | 4;
}
/** Image gallery grid: expandable fullscreen lightbox view. */
export function GalleryGrid({ images, columns = 3, className, ...props }: GalleryGridProps) {
const [active, setActive] = React.useState<number | null>(null);
const colClass = { 2: "grid-cols-2", 3: "grid-cols-3", 4: "grid-cols-4" }[columns];
React.useEffect(() => {
if (active === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActive(null);
if (e.key === "ArrowRight") setActive((a) => (a === null ? null : (a + 1) % images.length));
if (e.key === "ArrowLeft") setActive((a) => (a === null ? null : (a - 1 + images.length) % images.length));
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [active, images.length]);
return (
<div className={cn("w-full", className)} {...props}>
<style>{`@keyframes ggIn { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }`}</style>
<div className={cn("grid gap-2", colClass)}>
{images.map((img, idx) => (
<button
key={img.id}
onClick={() => setActive(idx)}
className="group relative aspect-square overflow-hidden rounded-md border border-border animate-[ggIn_0.35s_ease-out_both] motion-reduce:animate-none transition-transform duration-200 hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
style={{ animationDelay: `${idx * 50}ms` }}
aria-label={img.alt ?? `Image ${idx + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.src} alt={img.alt ?? ""} className="size-full object-cover transition-transform duration-300 group-hover:scale-105 motion-reduce:transition-none" loading="lazy" />
</button>
))}
</div>
{/* Lightbox */}
{active !== null && images[active] && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 animate-[fade-in_0.2s_ease-out]" onClick={() => setActive(null)}>
<div className="relative max-h-full max-w-2xl animate-[ggIn_0.25s_ease-out_both] motion-reduce:animate-none" onClick={(e) => e.stopPropagation()}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={images[active].src} alt={images[active].alt ?? ""} className="max-h-[80vh] rounded-lg border-2 border-dashed border-border object-contain" />
<div className="mt-2 flex items-center justify-between">
<button
onClick={() => setActive((a) => (a !== null ? (a - 1 + images.length) % images.length : null))}
className="rounded-sm border border-border bg-card px-3 py-1 font-mono text-[10px] uppercase text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none"
>
← Previous
</button>
<span className="font-mono text-[10px] text-muted-foreground">{active + 1} / {images.length}</span>
<button
onClick={() => setActive((a) => (a !== null ? (a + 1) % images.length : null))}
className="rounded-sm border border-border bg-card px-3 py-1 font-mono text-[10px] uppercase text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none"
>
Next →
</button>
</div>
</div>
<button
onClick={() => setActive(null)}
className="absolute right-4 top-4 rounded-sm p-1.5 text-white/70 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white motion-reduce:transition-none"
aria-label="Close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</div>
)}
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| images | GalleryImage[] | |
| columns | 2 | 3 | 4 |