# Gallery 3

> Infinite image marquee with edge mask — rolling visual library strip.

A shadcn/ui block from the [@nusaiba](https://nusaiba.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/nusaiba/gallery-3).

- Page, with a live preview: https://shadcn.exchange/r/nusaiba/gallery-3
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/nusaiba/gallery-3-light-7ac308526c5efbba.png
- Registry item: https://nusaiba.dev/r/base-nova/gallery-3.json
- Categories: gallery
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://nusaiba.dev/r/base-nova/gallery-3.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `@nusaiba/foundation`, `@nusaiba/gallery-visuals`

## Files

### `registry/blocks/gallery/gallery-data.ts`

```ts
export type GalleryCategory = "Motion" | "Light" | "Texture";

export type GalleryItem = {
	id: string;
	title: string;
	location: string;
	date: string;
	category: GalleryCategory;
	image: string;
	alt: string;
	summary: string;
};

export const galleryFilters = ["All", "Motion", "Light", "Texture"] as const;
export type GalleryFilter = (typeof galleryFilters)[number];

export const galleryItems: GalleryItem[] = [
	{
		id: "hyperspeed",
		title: "Velocity study",
		location: "Motion lab",
		date: "Mar 2026",
		category: "Motion",
		image: "/images/visuals/hyperspeed.png",
		alt: "Neon light trails flowing through a dark space",
		summary:
			"Directional light trails for hero backgrounds — high contrast without stock-photo noise.",
	},
	{
		id: "floating-lines",
		title: "Line field",
		location: "Motion lab",
		date: "Mar 2026",
		category: "Motion",
		image: "/images/visuals/floating-lines.png",
		alt: "Floating luminous lines on black",
		summary:
			"Weightless line fields that read as motion even in a still frame — ideal for product rails.",
	},
	{
		id: "plasma",
		title: "Plasma drift",
		location: "Motion lab",
		date: "Feb 2026",
		category: "Motion",
		image: "/images/visuals/plasma.png",
		alt: "Plasma smoke on a dark field",
		summary:
			"Soft plasma drift for atmospheric sections — pairs with serif headlines and sparse copy.",
	},
	{
		id: "light-rays",
		title: "Spotlight field",
		location: "Light series",
		date: "Feb 2026",
		category: "Light",
		image: "/images/visuals/light-rays.png",
		alt: "Soft white light rays on a black background",
		summary:
			"Spotlight cones for launch pages — editorial drama without gradient overload.",
	},
	{
		id: "orb",
		title: "Orbital glow",
		location: "Light series",
		date: "Feb 2026",
		category: "Light",
		image: "/images/visuals/orb.png",
		alt: "Glowing cyan and violet ring on black",
		summary:
			"Orbital glow for feature callouts — a single focal ring that scales from card to hero.",
	},
	{
		id: "dark-veil",
		title: "Veil bloom",
		location: "Light series",
		date: "Jan 2026",
		category: "Light",
		image: "/images/visuals/dark-veil.png",
		alt: "Purple light veil across darkness",
		summary:
			"Veil bloom for premium covers — depth without competing with foreground UI.",
	},
	{
		id: "color-bends",
		title: "Color bend",
		location: "Light series",
		date: "Jan 2026",
		category: "Light",
		image: "/images/visuals/color-bends.png",
		alt: "Smooth color bends on a dark canvas",
		summary:
			"Smooth spectral bends for brand moments — reads modern without neon cliché.",
	},
	{
		id: "silk",
		title: "Silk fold",
		location: "Texture study",
		date: "Jan 2026",
		category: "Texture",
		image: "/images/visuals/silk.png",
		alt: "Dark silk fabric with soft diagonal highlights",
		summary:
			"Silk folds for texture-led layouts — tactile contrast against flat UI chrome.",
	},
	{
		id: "pixel-blast",
		title: "Pixel scatter",
		location: "Texture study",
		date: "Jan 2026",
		category: "Texture",
		image: "/images/visuals/pixel-blast.png",
		alt: "Glowing pixel clusters on a dark field",
		summary:
			"Pixel scatter for dev-tool aesthetics — precise glow clusters on a dark field.",
	},
	{
		id: "ripple-grid",
		title: "Ripple grid",
		location: "Texture study",
		date: "Dec 2025",
		category: "Texture",
		image: "/images/visuals/ripple-grid.png",
		alt: "Distorted white grid on black",
		summary:
			"Ripple grid for motion marketing — distortion that hints at scroll without animation.",
	},
	{
		id: "threads",
		title: "Thread weave",
		location: "Texture study",
		date: "Dec 2025",
		category: "Texture",
		image: "/images/visuals/threads.png",
		alt: "Fine luminous threads crossing a dark plane",
		summary:
			"Thread weave for fine-detail backgrounds — subtle structure behind dense copy.",
	},
	{
		id: "beams",
		title: "Column light",
		location: "Motion lab",
		date: "Dec 2025",
		category: "Motion",
		image: "/images/visuals/beams.png",
		alt: "Vertical metallic light columns on black",
		summary:
			"Column light for architectural heroes — vertical rhythm that frames centered type.",
	},
];

export const gallerySpotlightCopy = {
	eyebrow: "Featured visual",
	title: "One frame, many moods",
	description: "Cycle through motion studies — hover a thumbnail or let the reel advance.",
} as const;

export const galleryMarqueeCopy = {
	title: "Rolling visual library",
	description: "Abstract covers for heroes, blog posts, and product storytelling.",
} as const;

export const gallerySplitCopy = {
	eyebrow: "Visual library",
	title: "Index rail for motion covers",
	description:
		"Typography-first list with a live preview panel — hover a row to swap the featured visual without leaving the section.",
} as const;

export const galleryBentoCopy = {
	title: "Bento exhibition",
	description: "Asymmetric wall — one hero tile with supporting studies around it.",
} as const;

export const galleryColumnsCopy = {
	eyebrow: "Visual library",
	title: "Staggered columns",
	description:
		"Offset masonry columns inside a framed wall — directional blur reveal and hover focus per column.",
} as const;

export const galleryMetricsCopy = {
	title: "Library at a glance",
	description: "Stats beside a compact grid of motion-ready covers.",
} as const;

export const galleryFilmstripCopy = {
	title: "Scroll the lineup",
	description: "Horizontal filmstrip with scroll-snap focus — blur fades on the active frame.",
} as const;

export const galleryRevealCopy = {
	title: "Reveal wall",
	description: "Directional blur stagger as tiles enter — each visual earns its moment.",
} as const;

export const galleryFramedCopy = {
	eyebrow: "Exhibition",
	title: "Framed visual cycle",
	description:
		"Decor-framed panel with grid pattern, centered metadata, and labeled selector chips — pauses on hover.",
} as const;

export const galleryMetrics = [
	{ value: "12", label: "Visual sets" },
	{ value: "4K", label: "Ready covers" },
	{ value: "Free", label: "In public/" },
] as const;
```

### `registry/blocks/gallery/gallery-primitives.tsx`

```tsx
"use client";

import Image from "next/image";
import { ArrowUpRightIcon } from "lucide-react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { useRef, type ReactNode } from "react";
import { defaultSpring, motionStagger, motionViewport } from "@/lib/motion-tokens";
import { cn } from "@/lib/utils";
import type { GalleryFilter, GalleryItem } from "./gallery-data";
import { galleryFilters } from "./gallery-data";

const spring = { type: "spring" as const, duration: 0.3, bounce: 0 };

export function useGalleryItemVariants() {
	const reduceMotion = useReducedMotion();

	return reduceMotion
		? { hidden: { opacity: 1 }, visible: { opacity: 1 } }
		: {
				hidden: { opacity: 0, filter: "blur(4px)", y: 8 },
				visible: { opacity: 1, filter: "blur(0px)", y: 0, transition: spring },
			};
}

export function GalleryStagger({
	children,
	className,
}: {
	children: ReactNode;
	className?: string;
}) {
	const ref = useRef<HTMLDivElement>(null);
	const inView = useInView(ref, motionViewport);

	return (
		<motion.div
			animate={inView ? "visible" : "hidden"}
			className={className}
			initial="hidden"
			ref={ref}
			variants={{
				hidden: {},
				visible: { transition: { staggerChildren: motionStagger.item, delayChildren: 0.04 } },
			}}
		>
			{children}
		</motion.div>
	);
}

export function GalleryStaggerItem({
	children,
	className,
}: {
	children: ReactNode;
	className?: string;
}) {
	const itemVariants = useGalleryItemVariants();

	return (
		<motion.div className={className} variants={itemVariants}>
			{children}
		</motion.div>
	);
}

export function GallerySectionHeader({
	eyebrow,
	title,
	description,
	className,
	align = "center",
}: {
	eyebrow?: string;
	title: string;
	description?: string;
	className?: string;
	align?: "center" | "left";
}) {
	return (
		<GalleryStagger
			className={cn(
				"max-w-2xl",
				align === "center" ? "mx-auto text-center" : "text-left",
				className,
			)}
		>
			{eyebrow ? (
				<GalleryStaggerItem>
					<p className="text-muted-foreground text-sm">{eyebrow}</p>
				</GalleryStaggerItem>
			) : null}
			<GalleryStaggerItem>
				<h2 className="mt-2 text-balance font-medium text-2xl tracking-tight md:text-3xl">
					{title}
				</h2>
			</GalleryStaggerItem>
			{description ? (
				<GalleryStaggerItem>
					<p className="mt-3 text-pretty text-muted-foreground text-sm leading-relaxed md:text-base">
						{description}
					</p>
				</GalleryStaggerItem>
			) : null}
		</GalleryStagger>
	);
}

export function GalleryImageFrame({
	item,
	className,
	sizes = "(max-width: 768px) 100vw, 50vw",
	priority,
	hoverZoom = true,
}: {
	item: GalleryItem;
	className?: string;
	sizes?: string;
	priority?: boolean;
	hoverZoom?: boolean;
}) {
	return (
		<div className={cn("absolute inset-0 overflow-hidden bg-muted", className)}>
			<div
				className={cn(
					"absolute inset-0",
					hoverZoom && "transition-transform duration-700 ease-out group-hover:scale-[1.04]",
				)}
			>
				<Image
					alt={item.alt}
					className="object-cover outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
					fill
					priority={priority}
					sizes={sizes}
					src={item.image}
				/>
			</div>
		</div>
	);
}

export function GalleryTileCaption({
	item,
	className,
	compact,
}: {
	item: GalleryItem;
	className?: string;
	compact?: boolean;
}) {
	return (
		<figcaption
			className={cn(
				"absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 bg-linear-to-t from-foreground/55 via-foreground/10 to-transparent p-4",
				className,
			)}
		>
			<div>
				<p
					className={cn(
						"font-medium text-white tracking-tight",
						compact ? "text-xs" : "text-sm",
					)}
				>
					{item.title}
				</p>
				<p className="mt-0.5 text-white/70 text-[10px] md:text-xs">
					{item.location} · {item.date}
				</p>
			</div>
			<span className="flex size-8 shrink-0 translate-y-1 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white opacity-0 backdrop-blur-sm transition-[opacity,transform] duration-300 group-hover:translate-y-0 group-hover:opacity-100">
				<ArrowUpRightIcon className="size-3.5" />
			</span>
		</figcaption>
	);
}

export function GalleryCategoryBadge({
	category,
	className,
}: {
	category: GalleryItem["category"];
	className?: string;
}) {
	return (
		<span
			className={cn(
				"absolute top-3 left-3 rounded-full border border-white/20 bg-black/20 px-2 py-0.5 font-medium text-[10px] text-white/90 uppercase tracking-[0.14em] backdrop-blur-sm",
				className,
			)}
		>
			{category}
		</span>
	);
}

export function GalleryTile({
	item,
	className,
	compact,
	hoverZoom = true,
}: {
	item: GalleryItem;
	className?: string;
	compact?: boolean;
	hoverZoom?: boolean;
}) {
	return (
		<figure className={cn("group relative overflow-hidden bg-muted", className)}>
			<GalleryImageFrame hoverZoom={hoverZoom} item={item} />
			<GalleryCategoryBadge category={item.category} />
			<GalleryTileCaption compact={compact} item={item} />
		</figure>
	);
}

export function GalleryFilterChips({
	active,
	onChange,
	className,
}: {
	active: GalleryFilter;
	onChange: (filter: GalleryFilter) => void;
	className?: string;
}) {
	return (
		<div className={cn("flex flex-wrap gap-2", className)}>
			{galleryFilters.map((filter) => {
				const isActive = active === filter;

				return (
					<button
						className={cn(
							"min-h-11 rounded-full border px-3.5 py-2 font-medium text-xs transition-[background-color,border-color,color,transform] active:scale-[0.96]",
							isActive
								? "border-foreground bg-foreground text-background"
								: "border-border bg-background text-muted-foreground hover:border-foreground/25 hover:text-foreground",
						)}
						key={filter}
						onClick={() => onChange(filter)}
						type="button"
					>
						{filter}
					</button>
				);
			})}
		</div>
	);
}

export function GalleryThumbButton({
	item,
	active,
	onSelect,
	className,
}: {
	item: GalleryItem;
	active: boolean;
	onSelect: () => void;
	className?: string;
}) {
	return (
		<button
			aria-label={`Show ${item.title}`}
			aria-pressed={active}
			className={cn(
				"relative min-h-11 min-w-11 shrink-0 overflow-hidden rounded-lg transition-[opacity,transform,box-shadow] active:scale-[0.96]",
				active
					? "opacity-100 shadow-[0_1px_2px_rgba(0,0,0,0.08)] ring-2 ring-foreground/80 ring-offset-2 ring-offset-background"
					: "opacity-55 hover:opacity-90",
				className,
			)}
			onClick={onSelect}
			type="button"
		>
			<Image
				alt=""
				className="object-cover outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
				fill
				sizes="64px"
				src={item.image}
			/>
		</button>
	);
}

export function GalleryImageReveal({
	item,
	className,
}: {
	item: GalleryItem;
	className?: string;
}) {
	return (
		<motion.figure
			animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
			className={cn(
				"group relative aspect-[4/3] overflow-hidden rounded-xl bg-muted md:aspect-[16/10]",
				className,
			)}
			exit={{ opacity: 0, y: -6, filter: "blur(4px)" }}
			initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
			key={item.id}
			transition={defaultSpring}
		>
			<GalleryImageFrame hoverZoom={false} item={item} sizes="(max-width: 768px) 100vw, 60vw" />
			<GalleryCategoryBadge category={item.category} />
			<GalleryTileCaption item={item} />
		</motion.figure>
	);
}

export function GalleryCaptionListItem({
	item,
	active,
	onSelect,
}: {
	item: GalleryItem;
	active: boolean;
	onSelect: () => void;
}) {
	return (
		<button
			className={cn(
				"flex min-h-11 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition-[background-color,opacity,transform] active:scale-[0.96]",
				active ? "bg-muted/50" : "opacity-70 hover:bg-muted/30 hover:opacity-100",
			)}
			onClick={onSelect}
			type="button"
		>
			<span className="relative size-10 shrink-0 overflow-hidden rounded-md">
				<Image
					alt=""
					className="object-cover outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
					fill
					sizes="40px"
					src={item.image}
				/>
			</span>
			<span className="min-w-0">
				<span className="block font-medium text-sm">{item.title}</span>
				<span className="block truncate text-muted-foreground text-xs">
					{item.location} · {item.date}
				</span>
			</span>
		</button>
	);
}

export function GalleryPresenceStage({
	item,
	className,
}: {
	item: GalleryItem;
	className?: string;
}) {
	return (
		<div className={cn("relative", className)}>
			<AnimatePresence initial={false} mode="wait">
				<GalleryImageReveal item={item} />
			</AnimatePresence>
		</div>
	);
}
```

### `registry/blocks/gallery/3/gallery-section.tsx`

```tsx
"use client";

import Image from "next/image";
import { motion, useReducedMotion } from "motion/react";
import { galleryItems, galleryMarqueeCopy } from "../gallery-data";
import { SectionShell, ContentRail } from "@/components/layout-contract";
import {
	GallerySectionHeader,
	GalleryTileCaption,
} from "../gallery-primitives";

const marqueeItems = [...galleryItems.slice(0, 8), ...galleryItems.slice(0, 8)];

export function GalleryMarqueeSection() {
	const reduceMotion = useReducedMotion();

	return (
		<SectionShell spacingMode="section">
			<ContentRail maxWidth="max-w-5xl" className="space-y-10">
				<GallerySectionHeader
					description={galleryMarqueeCopy.description}
					title={galleryMarqueeCopy.title}
				/>

				<div className="overflow-hidden border-border border-y py-4 [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]">
					<motion.div
						animate={reduceMotion ? undefined : { x: ["0%", "-50%"] }}
						className="flex w-max gap-4"
						transition={{ duration: 40, ease: "linear", repeat: Number.POSITIVE_INFINITY }}
					>
						{marqueeItems.map((item, index) => (
							<figure
								className="group relative h-40 w-[min(72vw,14rem)] shrink-0 overflow-hidden rounded-xl bg-muted md:h-48 md:w-56"
								key={`${item.id}-${index}`}
							>
								<Image
									alt={item.alt}
									className="object-cover outline outline-1 -outline-offset-1 outline-black/10 transition-transform duration-700 ease-out group-hover:scale-[1.04] dark:outline-white/10"
									fill
									sizes="224px"
									src={item.image}
								/>
								<GalleryTileCaption compact item={item} />
							</figure>
						))}
					</motion.div>
				</div>
			</ContentRail>
		</SectionShell>
	);
}
```
