Skip to content

HoverGallery

Data Display

Image switching on hover, focus, or tap with accessible thumbnail controls.

Image and caption switchingMouse/keyboard/touch choicesSquare or landscapeEmpty collection
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Use for a compact product, template, or portfolio gallery. Each image needs a unique id, source, meaningful alt text, and optional caption. Mouse hover, keyboard focus, and tap select a thumbnail and update the main image in place. The component does not autoplay or open a modal. Use Lightbox for a full-screen viewer. The current index clamps when images are removed, and an empty collection has an explicit message.

Import
import { HoverGallery } from "@/components/ui/hover-gallery";

Shipped as editable source in src/components/ui/hover-gallery.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/hover-gallery.tsx
"use client";
import { useState } from "react";
import NextImage from "next/image";
import { cn } from "@dashboardpack/core/lib/utils";
export interface GalleryImage {
  id: string;
  src: string;
  alt: string;
  caption?: string;
}
export interface HoverGalleryProps {
  label: string;
  images: GalleryImage[];
  onIndexChange?: (index: number) => void;
  square?: boolean;
  className?: string;
}
export function HoverGallery({
  label,
  images,
  onIndexChange,
  square = false,
  className,
}: HoverGalleryProps) {
  const [index, setIndex] = useState(0);
  const current = Math.min(index, Math.max(0, images.length - 1));
  if (!images.length)
    return (
      <p className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
        No gallery images available.
      </p>
    );
  const choose = (next: number) => {
    setIndex(next);
    onIndexChange?.(next);
  };
  const image = images[current];
  return (
    <figure className={cn("min-w-0 space-y-3", className)}>
      <div
        className={cn(
          "relative overflow-hidden rounded-xl border bg-muted",
          square ? "aspect-square" : "aspect-[4/3]",
        )}
      >
        <NextImage
          key={image.src}
          src={image.src}
          alt={image.alt}
          fill
          unoptimized
          sizes="640px"
          className="object-cover"
        />
      </div>
      <div
        role="group"
        aria-label={`${label} image choices`}
        className="flex flex-wrap gap-2"
      >
        {images.map((item, position) => (
          <button
            key={item.id}
            type="button"
            aria-label={`View ${item.alt}`}
            aria-pressed={current === position}
            onPointerEnter={(event) => {
              if (event.pointerType === "mouse") choose(position);
            }}
            onFocus={() => choose(position)}
            onClick={() => choose(position)}
            className={cn(
              "relative h-14 w-20 overflow-hidden rounded-lg border outline-none focus-visible:ring-2 focus-visible:ring-ring",
              current === position &&
                "ring-2 ring-primary ring-offset-2 ring-offset-background",
            )}
          >
            <NextImage
              src={item.src}
              alt=""
              fill
              unoptimized
              sizes="80px"
              className="object-cover"
            />
          </button>
        ))}
      </div>
      <figcaption className="text-xs text-muted-foreground">
        <span className="font-medium text-foreground">{label}</span> ·{" "}
        {image.caption ?? image.alt} · {current + 1}/{images.length}
      </figcaption>
    </figure>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

HoverGallery props
PropType / DefaultDescription
labelstring

Default: required

Visible collection label and control-group name.
imagesGalleryImage[]

Default: required

Unique id, src, alt, and optional caption.
onIndexChange(index: number) => void

Default: —

Observe the selected zero-based index.
squareboolean

Default: false

Use a square main image rather than 4:3.
classNamestring

Default: —

Extend the container with semantic theme utilities.

Build with these too