Image Gallery React component for Tailwind CSS v4
v1.0.0Added in 1.14.0
A React image gallery with masonry, cropped grid and justified rows layouts, tag filters, a lightbox and load more.
Photos fade in over a shimmering placeholder that already has their shape, so nothing jumps while they load. Masonry keeps each photo's shape and fills the shortest column, so the order still reads left to right; grid crops every photo to one ratio; rows packs justified rows like a photos app, every row one height and edge to edge. Tap a filter chip and the photos that leave fade out while the rest glide into their new places on a spring (a plain fade under reduced motion); on a phone the chips sit in one row you can swipe sideways. Tap a photo, or press Enter on it, and it grows into the full-screen lightbox, then flies back into its tile on close. The photos are one Tab stop: Left / Right move to the previous / next photo, Up / Down to the one above or below, Home / End to the first and last. Captions show on hover and focus with a mouse and stay visible on touch screens. With hasMore a Load more button appears (it spins while the next page loads), or with loadMode inView the next page loads as you scroll near the end. Phones get two columns by default.
Image Gallery is a Pro React component in the React media components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add image-gallery, together with Image Lightbox, Toggle Group, Skeleton, Button, useControllableState and 3 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add image-galleryImage Gallery is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import ImageGallery from @/components/ui/image-gallery.
import { ImageGallery, type GalleryImage } from "@/components/ui/image-gallery"
const photos: GalleryImage[] = [
{ src: "/photos/brooklyn-loft.jpg", alt: "Loft living room", width: 1600, height: 1000, caption: "Loft, Brooklyn", tags: ["Interiors"] },
{ src: "/photos/glacier-trail.jpg", alt: "Mountain trail", width: 1000, height: 1500, caption: "Glacier National Park", tags: ["Travel"] },
{ src: "/photos/road-bike.jpg", alt: "Steel road bike", width: 1200, height: 1200, caption: "Road bike", tags: ["Gear"] },
]
export function Portfolio() {
return <ImageGallery images={photos} layout="masonry" filters showCaptions="hover" />
}Props
images*GalleryImage[]{ src, alt, width?, height?, caption?, tags?, thumbnail?, srcSet?, sizes?, id? }. Pass width and height so each tile reserves its shape before the file loads; without them a photo uses aspectRatio until it loads.
layoutdefault"masonry""masonry" | "grid" | "rows"masonry keeps every photo's shape in balanced columns, grid crops them to aspectRatio, rows fills justified rows of one height computed from the photo ratios.
columnsdefault"{ base: 2, md: 3, lg: 4 }"number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number }Masonry and grid columns, 1 to 6, per breakpoint (sm 640px, md 768px, lg 1024px, xl 1280px). A number is one count everywhere. Two on phones, three on tablets and four on desktops by default.
gapdefault"md""none" | "sm" | "md" | "lg"Space between photos: 0, 4, 8 or 16px.
aspectRatiodefault"1/1"string | numberThe grid layout's crop, as "4/3", "4:3" or a number. Masonry and rows use it only for photos without width and height until they load.
targetRowHeightdefault240numberRows layout: the height each row aims for in px. Rows stretch a little to fill the width; below 640px they aim for 60% of it.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Tile corners: 0, 8, 12 or 16px; full falls back to lg like every surface.
showCaptionsdefault"hover""hover" | "always" | "none"Captions over the bottom of each photo: on hover and keyboard focus (always visible on touch screens), always, or never. The lightbox shows them either way.
hoverZoomdefaulttruebooleanThe photo zooms in 4% inside its tile on hover.
skeletondefault"shimmer""shimmer" | "pulse" | "none"The placeholder under each photo until it loads.
filtersdefaultfalseboolean | string[]Tag chips above the photos with an All chip first. true collects the tags from the images in order; an array sets which tags show and their order.
filterVariantdefault"outline""outline" | "soft" | "ghost"The look of the filter chips (the toggle group's variants).
filterstringThe active tag, controlled; "" shows all. Tags that are not in the chips count as all.
defaultFilterdefault""stringThe tag active on the first render, uncontrolled. The playground remounts the gallery when it changes.
onFilterChange(filter: string) => voidCalled with the new tag, or "" for All.
lightboxdefaulttruebooleanA tap or Enter opens the full-screen ImageLightbox at that photo, zooming out of the tile. Off, tiles are buttons only when onImageClick is set.
lightboxPropsOmit<ImageLightboxProps, "images" | "open" | "onOpenChange" | "index" | "onIndexChange" | "getOrigin">Passed to the lightbox: loop, zoom, download, share, showThumbnails, labels and the rest.
onImageClick(image: GalleryImage, index: number) => voidCalled when a photo is tapped or opened with the keyboard, before the lightbox opens. index is its place among the visible photos.
hasMoredefaultfalsebooleanMore photos exist: shows the Load more button (and, with loadMode inView, loads them as the end scrolls into view).
loadModedefault"button""button" | "inView"button waits for a tap on Load more; inView calls loadMore when the end of the gallery comes within 400px of the screen, and keeps the button as a fallback.
loadMore() => void | Promise<unknown>Fetches the next page. Return a promise and the button spins until it settles; never called twice at once.
loadingdefaultfalsebooleanThe next page is on its way (for a loadMore that does not return a promise): the button spins and in-view loading pauses.
labelsPartial<ImageGalleryLabels>Every string: gallery, filters, all, loadMore, loading, empty, emptyFiltered, error, results(count) for the live region and openImage(index, total, alt) for each tile.
classNamesPartial<Record<"filters" | "layout" | "item" | "tile" | "image" | "skeleton" | "caption" | "empty" | "footer" | "loadMore", string>>Extra classes for the inner parts: the chip row, the layout container, each list item, the tile button, the photo, its placeholder, the caption, the empty state and the load more row and button.
classNamestringExtra classes for the root, merged last.
Parts
Masonry
componentThe balanced masonry layout on its own, for any keyed children: cards, testimonials, notes. Each child goes to the shortest column in order and glides to its new place when the set changes; the server renders css columns, so the first paint already has the right columns. Takes div props.
columnsdefault"{ base: 2, md: 3, lg: 4 }"number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number }Columns per breakpoint, 1 to 6.
gapdefault"md""none" | "sm" | "md" | "lg" | numberA step of the gap scale (0 / 4 / 8 / 16px) or px.
animatedefaulttruebooleanItems glide on a spring when children come and go; off under reduced motion.
classNamesPartial<Record<"item", string>>Extra classes for the wrapper around each child.
GalleryImage
typeOne photo: { id?, src, alt, width?, height?, caption?, tags?, thumbnail?, srcSet?, sizes?, downloadName? }.
ImageGalleryLabels
typeThe strings the gallery shows and announces.
DEFAULT_IMAGE_GALLERY_LABELS
constantThe English labels, to spread and override for another language.
Examples
Masonry with filters
Tag chips with an All chip; photos that stay glide into place while the rest fade out.
Justified rows
Rows of one height that fill the width, computed from each photo's ratio.
Square grid
Cropped to 1:1 with square corners: three columns on phones, six on desktops.
Load on scroll
loadMode inView fetches the next page as the end scrolls near; the button stays as a fallback.
Masonry for any content
The Masonry part on its own, balancing review cards of any height.
Pick a photo
lightbox off with onImageClick: the tiles become a picker for a listing cover.
Dependencies
Files
- components/ui/image-gallery.tsx
FAQ
Is the React Image Gallery component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Image Gallery?
Run npx wingo-ui@latest add image-gallery. It also adds the Wingo UI items it builds on (Image Lightbox, Toggle Group, Skeleton, Button, useControllableState and 3 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/image-gallery.
How does Image Gallery behave on phones?
Tap a filter chip and the photos that leave fade out while the rest glide into their new places on a spring (a plain fade under reduced motion); on a phone the chips sit in one row you can swipe sideways.