component — display

Gallery

A presentational image gallery with a large hero image, a thumbnail strip for switching between images, and a click-to-zoom lightbox built on Modal. Styled with the quebi design system.

  • gallery
  • image
  • lightbox
  • carousel
  • media

03 examples

Default

Hero image plus a thumbnail strip. Click the hero to open the lightbox, then page with the arrows or arrow keys.

Single image

With one item the thumbnail strip and lightbox paging are hidden.

Empty

Falls back to a neutral placeholder box when there are no items.

source

gallery.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { ChevronLeft, ChevronRight, ImageIcon, ZoomIn } from "lucide-react"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { ModalContent } from "@/components/modal"
import { Button } from "react-aria-components"

export interface GalleryItem {
  id: string
  src: string
  alt?: string
}

export interface GalleryProps {
  items: GalleryItem[]
  className?: string
  /** Rendered in place of the hero when `items` is empty. Falls back to a
   * neutral placeholder box. */
  emptyState?: React.ReactNode
}

/**
 * Gallery — quebi design system
 *
 * Presentational image gallery: a large hero image with a thumbnail strip
 * below to switch between images, and a click-to-zoom lightbox built on Modal.
 * Selection is internal state — pass a stable `items` list and the component
 * keeps the active thumbnail in view. Pure client-side (no data fetching), so
 * it works for any image set.
 *
 * Square and ruled: hairline frames, the current thumbnail framed in ink, and
 * the lightbox on the page ground with square outline controls — no scrims or
 * blurred chips over the picture.
 */
export function Gallery({ items, className, emptyState }: GalleryProps) {
  const [selectedIndex, setSelectedIndex] = useState(0)
  const [lightboxOpen, setLightboxOpen] = useState(false)

  if (items.length === 0) {
    return (
      <div
        data-slot="gallery-empty"
        className={cn(
          "flex aspect-[4/3] w-full items-center justify-center rounded-(--q-radius-surface) border border-dashed border-quebi-hairline bg-quebi-raised text-quebi-fg-subtle",
          className,
        )}
      >
        {emptyState ?? <ImageIcon className="size-8" aria-hidden />}
      </div>
    )
  }

  // Clamp so the hero stays valid if `items` shrinks between renders.
  const activeIndex = Math.min(selectedIndex, items.length - 1)
  const active = items[activeIndex]
  const hasMultiple = items.length > 1

  // Step the selection with wraparound — drives both the inline hero and the
  // lightbox, so navigating in the lightbox keeps the strip in sync.
  const step = (delta: number) =>
    setSelectedIndex((current) => {
      const base = Math.min(current, items.length - 1)
      return (base + delta + items.length) % items.length
    })

  return (
    <div data-slot="gallery" className={cn("flex flex-col gap-2", className)}>
      <Button
        aria-label="Enlarge image"
        onPress={() => setLightboxOpen(true)}
        className="group relative flex aspect-[4/3] w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-(--q-radius-surface) border border-quebi-hairline bg-quebi-bg outline-hidden data-[focus-visible]:ring-2 data-[focus-visible]:ring-quebi-focus data-[focus-visible]:ring-offset-2 data-[focus-visible]:ring-offset-quebi-bg"
      >
        <img
          src={active.src}
          alt={active.alt ?? ""}
          className="size-full object-contain"
          loading="lazy"
        />
        <span className="absolute right-2 bottom-2 rounded-(--q-radius-mark) bg-quebi-action p-1.5 text-quebi-on-action opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[focus-visible]:opacity-100">
          <ZoomIn className="size-4" aria-hidden />
        </span>
      </Button>

      {hasMultiple && (
        <ul
          // p-1 (not just pb-1): overflow-x-auto also clips vertically, so the
          // selected thumbnail's ring needs room on every side or it's cut off.
          data-slot="gallery-thumbnails"
          className="m-0 flex list-none gap-2 overflow-x-auto p-1"
        >
          {items.map((item, index) => (
            <li key={item.id}>
              <Button
                aria-label={item.alt || `Image ${index + 1}`}
                aria-current={index === activeIndex}
                onPress={() => setSelectedIndex(index)}
                className={cn(
                  "size-14 shrink-0 overflow-hidden rounded-(--q-radius-control) border bg-quebi-bg outline-hidden transition-colors duration-150",
                  "data-[focus-visible]:ring-2 data-[focus-visible]:ring-quebi-focus data-[focus-visible]:ring-inset",
                  // Current: a signal frame, the border doubled by a 1px ring
                  // so the thumbnail does not change size.
                  index === activeIndex
                    ? "border-quebi-signal ring-1 ring-quebi-signal"
                    : "border-quebi-hairline hover:border-quebi-fg-subtle",
                )}
              >
                <img src={item.src} alt="" className="size-full object-contain" loading="lazy" />
              </Button>
            </li>
          ))}
        </ul>
      )}

      {/* State opens this, not an element, so ModalContent carries the open
          state itself — wrapping it in `Modal` (a DialogTrigger) would put it
          in the trigger slot with nothing to press. Focus returns to the hero
          button on close either way; that is the overlay's focus scope. */}
      <ModalContent
        isOpen={lightboxOpen}
        onOpenChange={setLightboxOpen}
        size="4xl"
        aria-label={active.alt ?? "Image"}
        className="p-0!"
      >
        {/* biome-ignore lint/a11y/noStaticElementInteractions: the Dialog owns
            focus; this only adds arrow-key paging on top of the nav buttons. */}
        <div
          className="relative flex items-center justify-center bg-quebi-bg"
          onKeyDown={(event) => {
            if (event.key === "ArrowLeft") {
              event.preventDefault()
              step(-1)
            } else if (event.key === "ArrowRight") {
              event.preventDefault()
              step(1)
            }
          }}
        >
          <img
            src={active.src}
            alt={active.alt ?? ""}
            className="max-h-[80vh] w-full object-contain"
          />
          {hasMultiple && (
            <>
              <Button
                aria-label="Previous image"
                onPress={() => step(-1)}
                className="absolute start-2 rounded-(--q-radius-control) border border-quebi-hairline bg-quebi-bg p-2 text-quebi-fg-muted outline-hidden transition-colors duration-150 hover:bg-quebi-raised hover:text-quebi-fg data-[focus-visible]:ring-2 data-[focus-visible]:ring-quebi-focus data-[focus-visible]:ring-offset-2 data-[focus-visible]:ring-offset-quebi-bg"
              >
                <ChevronLeft className="size-6" aria-hidden />
              </Button>
              <Button
                aria-label="Next image"
                onPress={() => step(1)}
                className="absolute end-2 rounded-(--q-radius-control) border border-quebi-hairline bg-quebi-bg p-2 text-quebi-fg-muted outline-hidden transition-colors duration-150 hover:bg-quebi-raised hover:text-quebi-fg data-[focus-visible]:ring-2 data-[focus-visible]:ring-quebi-focus data-[focus-visible]:ring-offset-2 data-[focus-visible]:ring-offset-quebi-bg"
              >
                <ChevronRight className="size-6" aria-hidden />
              </Button>
              <span className="absolute bottom-3 rounded-(--q-radius-mark) bg-quebi-bg px-2 py-1 font-mono text-quebi-fg-subtle text-quebi-label tabular-nums">
                {activeIndex + 1} / {items.length}
              </span>
            </>
          )}
        </div>
      </ModalContent>
    </div>
  )
}