Gallery
Preview
Basic
Loading…
Preview
Code
ts
import Gallery from '@/components/media/Gallery';src/components/media/Gallery.tsx
AI prompt
text
Build an image gallery component (main stage, thumbnail strip, captions and a fullscreen lightbox) in React + TypeScript + Tailwind CSS.
## Look
- `<section class="flex gap-2">`: `flex-col` for top/bottom thumbnails, `flex-row` for left/right. The strip renders before the stage for `top` and `left`.
- Stage: `group relative w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-900`, with `aspect-ratio: stageAspect` (default "16 / 10", so the page does not jump between images of different shapes). It is focusable (`tabIndex={0}`, `focus-visible:ring-2 ring-indigo-400`), and the image fills it with `object-contain`.
- Overlay arrows: `absolute top-1/2 -translate-y-1/2` at `left-2` / `right-2`, `h-8 w-8 rounded-full bg-slate-900/45 text-white backdrop-blur-sm hover:bg-slate-900/70 dark:bg-slate-950/55 dark:hover:bg-slate-950/80`, with 16px chevrons. At a non-circular end they get `aria-disabled` and `opacity-30` (not `disabled`, which would drop focus).
- Fullscreen button: `absolute right-2 top-2`, 28px `rounded-md` with the same translucent dark fill and a 14px Maximize2 icon.
- Caption: `pointer-events-none absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950/75 to-transparent px-3 pb-2.5 pt-8 text-xs text-white`.
- Broken image: a centred 24px ImageOff icon over the alt text (11px, slate-400 / dark slate-500). Key the image by `src` so the fallback doesn't stick to the next image.
- Optional dots under the stage: `h-1.5 rounded-full`, active `w-5 bg-indigo-600 dark:bg-indigo-400`, others `w-1.5 bg-slate-300 dark:bg-slate-600`.
- Thumbnail strip, hidden with only one image:
- Horizontal: `relative flex gap-1.5 overflow-x-auto pb-1` with a thin scrollbar and `h-14 w-20` thumbs.
- Vertical: a `relative w-20 shrink-0 self-stretch` wrapper holding an `absolute inset-0 flex flex-col gap-1.5 overflow-y-auto pr-0.5` strip, so it takes the stage's height instead of stretching the row to fit every thumb. Thumbs are `w-full aspect-[4/3]`.
- Each thumb: `rounded-md overflow-hidden bg-slate-100 dark:bg-slate-800` with an `object-cover` image. Active: `opacity-100 ring-2 ring-inset ring-indigo-500 dark:ring-indigo-400`. Others: `opacity-60 hover:opacity-100`.
- No images: a dashed box, `p-6 text-[11px] text-slate-400`, reading "No images."
## Behaviour
- Controlled (`activeIndex` + `onActiveIndexChange`) or uncontrolled (`defaultActiveIndex`). `circular` wraps at both ends; otherwise the index clamps.
- The strip keeps the active thumb centred by smooth-scrolling ITS OWN `scrollLeft` / `scrollTop`. Never use `scrollIntoView`, which scrolls the page too and yanks a gallery below the fold into view.
- Stage keys: Left / Right step, Home / End jump. Enter on the stage itself (not on the buttons inside it) opens the lightbox.
- Lightbox: portalled to <body>, `fixed inset-0 z-[200] flex flex-col bg-slate-950/90 backdrop-blur-sm outline-none`, fading in (opacity 0 → 1, 200ms ease-out). It is dark in BOTH themes, so it has no `dark:` pairs: a photo is judged against black.
- Top bar: `flex justify-between px-4 py-3 text-xs text-slate-300`, showing "3 / 8" (`aria-live="polite"`) and a 32px round close button (`hover:bg-white/10 hover:text-white`).
- Middle: `relative flex min-h-0 flex-1 items-center justify-center px-16 pb-6` holding a <figure>. The image is `max-h-[80vh] max-w-full rounded-md object-contain` and fades in on each change. The figcaption is `max-w-2xl text-center text-xs text-slate-300`.
- Arrows: 40px round `bg-white/10 hover:bg-white/20 text-white` at `left-3` / `right-3`, with 20px chevrons; at an end, `aria-disabled` with `opacity-25`.
- A mousedown on the dark space (outside the figure, the top bar and the arrows) closes it, as does Escape. Arrows and Home / End navigate.
- On open, focus moves to the close button. Tab wraps among the lightbox's buttons. On close, focus returns to whatever opened it. The dialog is `tabIndex={-1}`, so a click on the image keeps the keyboard handling.
- `fullscreen` + `onFullscreenChange` optionally control the lightbox.
## API
- `GalleryImage = { src: string; thumbnail?: string; alt: string; caption?: ReactNode }`
- `images`, `activeIndex?`, `defaultActiveIndex?` = 0, `onActiveIndexChange?`
- `showThumbnails?` = true, `thumbnailsPosition?: 'bottom' | 'top' | 'left' | 'right'` = `'bottom'`, `showIndicators?` = false, `showCaption?` = true
- `circular?` = false, `allowFullscreen?` = true, `fullscreen?`, `onFullscreenChange?`
- `stageAspect?` = '16 / 10', `'aria-label'?` = "Image gallery", `className?`
## Accessibility
- Section: `aria-label`. Stage: `role="group" aria-roledescription="image viewer" aria-label="Image 3 of 8: {alt}"`, plus an `sr-only aria-live="polite"` line with the same text.
- Thumbs: `aria-label="Show image n: {alt}"` and `aria-current`. Lightbox: `role="dialog" aria-modal="true" aria-label="Image viewer"`. Buttons: "Previous image", "Next image", "View fullscreen", "Close viewer".
## Demo
Eight picsum.photos images (seeds: lake, forest, desert, harbor, meadow, canyon, glacier, valley) at 1200×750, with 160×112 thumbnails and captions like "**Photo 3** of 8 — random sample image". Make it `circular` and `max-w-2xl`, with a row of buttons switching the thumbnail position between bottom, top, left and right.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, ImageOff, Maximize2, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
export interface GalleryImage {
src: string;
/** A smaller file for the strip. Falls back to `src`. */
thumbnail?: string;
alt: string;
caption?: ReactNode;
}
export type GalleryThumbnailsPosition = 'bottom' | 'top' | 'left' | 'right';
export interface GalleryProps {
images: GalleryImage[];
/** Controlled active image; pair with `onActiveIndexChange`. */
activeIndex?: number;
defaultActiveIndex?: number;
onActiveIndexChange?: (index: number) => void;
showThumbnails?: boolean;
thumbnailsPosition?: GalleryThumbnailsPosition;
/** Dots under the stage — an alternative to thumbnails for a short set. */
showIndicators?: boolean;
/** Draws each image's `caption` over the bottom of the stage. */
showCaption?: boolean;
/** Next from the last image goes to the first, and back. */
circular?: boolean;
/** Shows the expand button that opens the lightbox. */
allowFullscreen?: boolean;
/** Controlled lightbox; pair with `onFullscreenChange`. Omit to let the expand button own it. */
fullscreen?: boolean;
onFullscreenChange?: (open: boolean) => void;
/** CSS `aspect-ratio` of the stage. Fixing it keeps the page from jumping between images of different shapes. */
stageAspect?: string;
'aria-label'?: string;
className?: string;
}
// Keyed by `src` at the call site, so a failed image's fallback does not
// stick to the next image shown in the same slot.
function Img({ image, className, lazy = true }: { image: GalleryImage; className?: string; lazy?: boolean }) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 text-slate-400 dark:text-slate-500">
<ImageOff aria-hidden className="h-6 w-6" />
<span className="px-3 text-center text-[11px]">{image.alt}</span>
</div>
);
}
return (
// Plain <img>, not next/image: the file has to work pasted into any React
// project, and next/image would also need every remote host whitelisted.
// eslint-disable-next-line @next/next/no-img-element
<img
src={image.src}
alt={image.alt}
loading={lazy ? 'lazy' : 'eager'}
decoding="async"
onError={() => setFailed(true)}
className={className}
/>
);
}
const OVERLAY_NAV =
'absolute top-1/2 z-[1] inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-slate-900/45 text-white backdrop-blur-sm transition-colors hover:bg-slate-900/70 aria-disabled:cursor-default aria-disabled:opacity-30 aria-disabled:hover:bg-slate-900/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 dark:bg-slate-950/55 dark:hover:bg-slate-950/80';
// End-of-set nav buttons are `aria-disabled`, not `disabled`: a real
// `disabled` on the button that has focus drops focus to <body> the moment
// you reach the first image, and the next arrow key goes nowhere.
const endProps = (off: boolean, go: () => void) => ({
'aria-disabled': off || undefined,
onClick: () => {
if (!off) go();
},
});
/**
* A stage for one image at a time, with a thumbnail strip, optional dots and
* caption, and a fullscreen lightbox.
*
* The strip keeps the active thumbnail in view by setting ITS OWN scroll
* position, not with `scrollIntoView`. That method scrolls every scrollable
* ancestor to reveal the element — including the page — so a gallery below the
* fold yanked the page down to itself on mount, and again on every autoplayed
* or keyboard change.
*
* The strip is allowed to overflow (the kit otherwise avoids nested scrollers,
* because they clip popovers): nothing inside it opens one.
*
* The lightbox is PORTALLED to <body> at z-[200], the portalled-overlay band,
* for the same reason `Modal` is: `fixed` resolves against any ancestor with a
* transform or backdrop-filter, and `.panel` has one. Focus moves into it on
* open, Tab is kept inside, and focus returns to whatever opened it.
*/
export default function Gallery({
images,
activeIndex,
defaultActiveIndex = 0,
onActiveIndexChange,
showThumbnails = true,
thumbnailsPosition = 'bottom',
showIndicators = false,
showCaption = true,
circular = false,
allowFullscreen = true,
fullscreen,
onFullscreenChange,
stageAspect = '16 / 10',
'aria-label': ariaLabel = 'Image gallery',
className,
}: GalleryProps) {
const [innerIndex, setInnerIndex] = useState(defaultActiveIndex);
const [innerOpen, setInnerOpen] = useState(false);
const stripRef = useRef<HTMLDivElement>(null);
const thumbRefs = useRef<Array<HTMLButtonElement | null>>([]);
const count = images.length;
const index = count ? Math.min(Math.max(0, activeIndex ?? innerIndex), count - 1) : 0;
const open = fullscreen ?? innerOpen;
const current = images[index];
const goTo = (i: number) => {
if (!count) return;
const next = circular ? (i + count) % count : Math.min(Math.max(0, i), count - 1);
if (next === index) return;
if (activeIndex === undefined) setInnerIndex(next);
onActiveIndexChange?.(next);
};
const setOpen = (v: boolean) => {
if (fullscreen === undefined) setInnerOpen(v);
onFullscreenChange?.(v);
};
const vertical = thumbnailsPosition === 'left' || thumbnailsPosition === 'right';
useEffect(() => {
const strip = stripRef.current;
const thumb = thumbRefs.current[index];
if (!strip || !thumb) return;
// Centre the thumb in the strip. `offsetLeft/Top` are relative to the
// strip because it is the thumb's offsetParent (it is `relative`).
if (vertical) strip.scrollTo({ top: thumb.offsetTop - (strip.clientHeight - thumb.offsetHeight) / 2, behavior: 'smooth' });
else strip.scrollTo({ left: thumb.offsetLeft - (strip.clientWidth - thumb.offsetWidth) / 2, behavior: 'smooth' });
}, [index, vertical]);
const onStageKey = (e: KeyboardEvent<HTMLDivElement>) => {
const map: Record<string, number | undefined> = {
ArrowLeft: index - 1,
ArrowRight: index + 1,
Home: 0,
End: count - 1,
};
if (e.key in map) {
e.preventDefault();
goTo(map[e.key]!);
} else if (e.key === 'Enter' && allowFullscreen && e.target === e.currentTarget) {
// Only on the stage itself: Enter on the prev/next buttons inside it
// bubbles here too, and must stay a click on that button.
e.preventDefault();
setOpen(true);
}
};
if (!count) {
return (
<div className={cn('flex items-center justify-center rounded-lg border border-dashed border-slate-200 p-6 text-[11px] text-slate-400 dark:border-slate-700 dark:text-slate-500', className)}>
No images.
</div>
);
}
const atStart = !circular && index === 0;
const atEnd = !circular && index === count - 1;
const stage = (
// `relative` anchors the `sr-only` live region below: it is absolute, and
// unanchored it escapes a scrolling container and lengthens the document.
<div className="relative flex min-w-0 flex-1 flex-col gap-2">
<div
tabIndex={0}
role="group"
aria-roledescription="image viewer"
aria-label={`Image ${index + 1} of ${count}: ${current.alt}`}
onKeyDown={onStageKey}
className="group relative w-full overflow-hidden rounded-lg bg-slate-100 outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 dark:bg-slate-900"
style={{ aspectRatio: stageAspect }}
>
<Img key={current.src} image={current} lazy={false} className="h-full w-full object-contain" />
{count > 1 && (
<>
<button type="button" {...endProps(atStart, () => goTo(index - 1))} aria-label="Previous image" className={cn(OVERLAY_NAV, 'left-2')}>
<ChevronLeft aria-hidden className="h-4 w-4" />
</button>
<button type="button" {...endProps(atEnd, () => goTo(index + 1))} aria-label="Next image" className={cn(OVERLAY_NAV, 'right-2')}>
<ChevronRight aria-hidden className="h-4 w-4" />
</button>
</>
)}
{allowFullscreen && (
<button
type="button"
onClick={() => setOpen(true)}
aria-label="View fullscreen"
className="absolute right-2 top-2 z-[1] inline-flex h-7 w-7 items-center justify-center rounded-md bg-slate-900/45 text-white backdrop-blur-sm transition-colors hover:bg-slate-900/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 dark:bg-slate-950/55 dark:hover:bg-slate-950/80"
>
<Maximize2 aria-hidden className="h-3.5 w-3.5" />
</button>
)}
{showCaption && current.caption && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950/75 to-transparent px-3 pb-2.5 pt-8 text-xs text-white">
{current.caption}
</div>
)}
</div>
{showIndicators && count > 1 && (
<div className="flex items-center justify-center gap-1.5">
{images.map((img, i) => (
<button
key={i}
type="button"
onClick={() => goTo(i)}
aria-label={`Go to image ${i + 1}`}
aria-current={i === index || undefined}
className={cn(
'h-1.5 rounded-full transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400',
i === index
? 'w-5 bg-indigo-600 dark:bg-indigo-400'
: 'w-1.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-600 dark:hover:bg-slate-500',
)}
/>
))}
</div>
)}
{/* The index a screen reader hears after using the buttons or arrows. */}
<p aria-live="polite" className="sr-only">
Image {index + 1} of {count}: {current.alt}
</p>
</div>
);
const strip = showThumbnails && count > 1 && (
<div className={cn(vertical ? 'relative w-20 shrink-0 self-stretch' : 'w-full')}>
<div
ref={stripRef}
className={cn(
// Positioned either way, so it is the thumbs' offsetParent (see the
// scroll effect). Vertical is absolute so the strip takes the STAGE's
// height instead of stretching the row to fit every thumb.
'flex gap-1.5 scrollbar-thin',
vertical ? 'absolute inset-0 flex-col overflow-y-auto pr-0.5' : 'relative overflow-x-auto pb-1',
)}
>
{images.map((img, i) => (
<button
key={img.src + i}
ref={(el) => {
thumbRefs.current[i] = el;
}}
type="button"
onClick={() => goTo(i)}
aria-label={`Show image ${i + 1}: ${img.alt}`}
aria-current={i === index || undefined}
className={cn(
'relative shrink-0 overflow-hidden rounded-md bg-slate-100 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 dark:bg-slate-800',
vertical ? 'aspect-[4/3] w-full' : 'h-14 w-20',
i === index
? 'opacity-100 ring-2 ring-inset ring-indigo-500 dark:ring-indigo-400'
: 'opacity-60 hover:opacity-100',
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.thumbnail ?? img.src} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
</button>
))}
</div>
</div>
);
return (
<section aria-label={ariaLabel} className={cn('flex gap-2', vertical ? 'flex-row' : 'flex-col', className)}>
{(thumbnailsPosition === 'top' || thumbnailsPosition === 'left') && strip}
{stage}
{(thumbnailsPosition === 'bottom' || thumbnailsPosition === 'right') && strip}
{open && (
<Lightbox
images={images}
index={index}
onIndex={goTo}
circular={circular}
onClose={() => setOpen(false)}
/>
)}
</section>
);
}
function Lightbox({
images,
index,
onIndex,
circular,
onClose,
}: {
images: GalleryImage[];
index: number;
onIndex: (i: number) => void;
circular: boolean;
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const figureRef = useRef<HTMLElement>(null);
const barRef = useRef<HTMLDivElement>(null);
const prevRef = useRef<HTMLButtonElement>(null);
const nextRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const count = images.length;
const current = images[index];
// The dark space around the image is the backdrop: a press there is
// "outside" everything that has a job, and closes, as Escape does.
useDismiss([figureRef, barRef, prevRef, nextRef], true, onClose);
// Mounted only while open, so mount/unmount IS open/close: remember the
// opener, move focus in, and hand it back on the way out.
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
closeRef.current?.focus();
return () => opener?.focus?.();
}, []);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowLeft') onIndex(index - 1);
else if (e.key === 'ArrowRight') onIndex(index + 1);
else if (e.key === 'Home') onIndex(0);
else if (e.key === 'End') onIndex(count - 1);
else if (e.key === 'Tab') {
// A minimal focus trap: wrap Tab at either end of the dialog's own
// controls, so focus cannot wander into the page hidden behind it.
const focusables = [...(dialogRef.current?.querySelectorAll<HTMLElement>('button') ?? [])];
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
return;
} else return;
e.preventDefault();
};
const navBtn =
'absolute top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 aria-disabled:cursor-default aria-disabled:opacity-25 aria-disabled:hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400';
const content = (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Image viewer"
// -1 so a click on the image or the dark space focuses the dialog rather
// than <body>, which would take the arrow keys with it.
tabIndex={-1}
onKeyDown={onKeyDown}
// Dark in BOTH themes, deliberately, so no `dark:` pairs below: a
// photo is judged against black, and a white lightbox in light mode
// would glare around every image.
className="fixed outline-none inset-0 z-[200] flex flex-col bg-slate-950/90 backdrop-blur-sm animate-fade-in"
>
<div ref={barRef} className="flex items-center justify-between gap-3 self-stretch px-4 py-3 text-xs text-slate-300">
<span aria-live="polite">
{index + 1} / {count}
</span>
<button
ref={closeRef}
type="button"
onClick={onClose}
aria-label="Close viewer"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
<X aria-hidden className="h-4 w-4" />
</button>
</div>
<div className="relative flex min-h-0 flex-1 items-center justify-center px-16 pb-6">
<figure ref={figureRef} className="flex max-h-full max-w-full flex-col items-center gap-2">
<Img key={current.src} image={current} lazy={false} className="max-h-[80vh] max-w-full rounded-md object-contain animate-fade-in" />
{current.caption && <figcaption className="max-w-2xl text-center text-xs text-slate-300">{current.caption}</figcaption>}
</figure>
{count > 1 && (
<>
<button ref={prevRef} type="button" {...endProps(!circular && index === 0, () => onIndex(index - 1))} aria-label="Previous image" className={cn(navBtn, 'left-3')}>
<ChevronLeft aria-hidden className="h-5 w-5" />
</button>
<button ref={nextRef} type="button" {...endProps(!circular && index === count - 1, () => onIndex(index + 1))} aria-label="Next image" className={cn(navBtn, 'right-3')}>
<ChevronRight aria-hidden className="h-5 w-5" />
</button>
</>
)}
</div>
</div>
);
return typeof document === 'undefined' ? content : createPortal(content, document.body);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
images* | GalleryImage[] | — | |
activeIndex | number | — | Controlled active image; pair with `onActiveIndexChange`. |
defaultActiveIndex | number | 0 | |
onActiveIndexChange | (index: number) => void | — | |
showThumbnails | boolean | true | |
thumbnailsPosition | GalleryThumbnailsPosition | 'bottom' | |
showIndicators | boolean | false | Dots under the stage — an alternative to thumbnails for a short set. |
showCaption | boolean | true | Draws each image's `caption` over the bottom of the stage. |
circular | boolean | false | Next from the last image goes to the first, and back. |
allowFullscreen | boolean | true | Shows the expand button that opens the lightbox. |
fullscreen | boolean | — | Controlled lightbox; pair with `onFullscreenChange`. Omit to let the expand button own it. |
onFullscreenChange | (open: boolean) => void | — | |
stageAspect | string | '16 / 10' | CSS `aspect-ratio` of the stage. Fixing it keeps the page from jumping between images of different shapes. |
className | string | — |