ImageGallery
LG · 3300B gzip budgetLightbox gallery with keyboard navigation and zoom.
- Category
- Media & layout
- Budget
- lg, composes several components
- External runtime
- React / React DOM only
axe fixtureSSR rendersource scanbundle budget
Preview
Live fixtureTry the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.
Install
npx shadcn@latest add https://gear5-ui.vercel.app/r/image-gallery.jsonCopies the source into your project.
New here? Set up Tailwind and import aliases first →Source & props
Edit on GitHub ↗Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
export interface ImageGalleryProps {
images: { src: string; alt: string }[];
columns?: number;
}
export function ImageGallery({ images, columns = 3 }: ImageGalleryProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const lightboxRef = useRef<HTMLDivElement>(null);
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index);
}, []);
const closeLightbox = useCallback(() => {
setLightboxIndex(null);
}, []);
const goNext = useCallback(() => {
if (lightboxIndex === null) return;
setLightboxIndex((lightboxIndex + 1) % images.length);
}, [lightboxIndex, images.length]);
const goPrev = useCallback(() => {
if (lightboxIndex === null) return;
setLightboxIndex((lightboxIndex - 1 + images.length) % images.length);
}, [lightboxIndex, images.length]);
useEffect(() => {
if (lightboxIndex === null) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeLightbox();
if (e.key === "ArrowRight") goNext();
if (e.key === "ArrowLeft") goPrev();
};
document.addEventListener("keydown", handleKey);
lightboxRef.current?.focus();
return () => document.removeEventListener("keydown", handleKey);
}, [lightboxIndex, closeLightbox, goNext, goPrev]);
return (
<div>
<div
role="group"
aria-label="Image gallery"
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: "0.5rem",
}}
>
{images.map((img, i) => (
<button
key={i}
onClick={() => openLightbox(i)}
aria-label={`View ${img.alt}`}
style={{
padding: 0,
border: "none",
borderRadius: "0.5rem",
overflow: "hidden",
cursor: "pointer",
background: "none",
}}
>
<img
src={img.src}
alt={img.alt}
style={{
width: "100%",
height: 160,
objectFit: "cover",
display: "block",
}}
/>
</button>
))}
</div>
{lightboxIndex !== null && (
<div
ref={lightboxRef}
role="dialog"
aria-modal="true"
aria-label="Image lightbox"
tabIndex={-1}
onClick={closeLightbox}
style={{
position: "fixed",
inset: 0,
zIndex: 10000,
backgroundColor: "rgba(0,0,0,0.9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<button
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
aria-label="Previous image"
style={{
position: "absolute",
left: "1rem",
background: "rgba(255,255,255,0.2)",
border: "none",
color: "#fff",
width: 44,
height: 44,
borderRadius: "50%",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M12.5 15l-5-5 5-5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
</button>
<img
src={images[lightboxIndex].src}
alt={images[lightboxIndex].alt}
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: "90vw", maxHeight: "85vh", borderRadius: "0.5rem" }}
/>
<button
onClick={(e) => {
e.stopPropagation();
goNext();
}}
aria-label="Next image"
style={{
position: "absolute",
right: "1rem",
background: "rgba(255,255,255,0.2)",
border: "none",
color: "#fff",
width: 44,
height: 44,
borderRadius: "50%",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M7.5 15l5-5-5-5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
</button>
<button
onClick={closeLightbox}
aria-label="Close lightbox"
style={{
position: "absolute",
top: "1rem",
right: "1rem",
background: "rgba(255,255,255,0.2)",
border: "none",
color: "#fff",
width: 36,
height: 36,
borderRadius: "50%",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
)}
</div>
);
}
What CI checks
Quality contract- Bundled, minified and gzipped against its tier budget
- Audited by axe in the state previewed above
- Rendered through react-dom/server with no browser globals
- Scanned for network calls, dangerous sinks, and unguarded animation