HoverGallery
Data DisplayImage switching on hover, focus, or tap with accessible thumbnail controls.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { HoverGallery } from "@/components/ui/hover-gallery";
export default function HoverGalleryExample() {
return (
<div className="grid w-full max-w-3xl gap-6 md:grid-cols-2">
<HoverGallery
label="The studio collection"
images={[
{
id: "coast",
src: "/images/component-demos/coast.svg",
alt: "Layered coastal waves",
caption: "Coastal calm",
},
{
id: "summit",
src: "/images/component-demos/summit.svg",
alt: "Snowy mountain peaks",
caption: "Higher ground",
},
{
id: "desert",
src: "/images/component-demos/desert.svg",
alt: "Warm desert dunes",
caption: "Quiet places",
},
]}
/>
<div className="space-y-5">
<div className="rounded-xl border bg-card p-5">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Art for your workspace
</p>
<h3 className="mt-3 text-2xl font-semibold tracking-tight">
A change of perspective.
</h3>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Hover over a thumbnail, focus it with the keyboard, or tap to
explore. The main image updates in place.
</p>
</div>
<HoverGallery label="An empty collection" images={[]} />
</div>
</div>
);
}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 { 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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible collection label and control-group name. |
| images | GalleryImage[]Default: required | Unique id, src, alt, and optional caption. |
| onIndexChange | (index: number) => voidDefault: — | Observe the selected zero-based index. |
| square | booleanDefault: false | Use a square main image rather than 4:3. |
| className | stringDefault: — | Extend the container with semantic theme utilities. |