A listing gallery with a crossfading main photo, a thumbnail strip including a map-pin location tile, and key details.
npx shadcn@latest add @paragon/property-gallery"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { MapPin, BedDouble, Bath, Maximize } from "lucide-react";
import { cn } from "@/lib/utils";
export interface GalleryPhoto {
src?: string;
alt?: string;
/** Placeholder tint hue (deg) when src is absent. */
hue?: number;
}
export interface PropertyGalleryProps extends React.ComponentProps<"div"> {
address: string;
location: string;
price: number;
beds: number;
baths: number;
sqft: number;
photos: GalleryPhoto[];
/** Normalized map-pin position 0–1 for the location thumbnail. */
pin?: { x: number; y: number };
/** Disables the main-photo crossfade. */
static?: boolean;
}
function Placeholder({ hue = 250 }: { hue?: number }) {
return (
<div
aria-hidden
className="size-full"
style={{
background: `linear-gradient(135deg, oklch(0.72 0.05 ${hue}), oklch(0.42 0.03 ${hue + 20}))`,
}}
/>
);
}
/**
* A listing gallery: a large main photo with a thumbnail strip (one being a
* mini map with a location pin), price + details, all in one card. The main
* photo crossfades with a small blur on selection; reduced motion swaps in
* place. Deterministic — selection is local state, pin is a prop.
*/
export function PropertyGallery({
address,
location,
price,
beds,
baths,
sqft,
photos,
pin = { x: 0.5, y: 0.45 },
static: isStatic = false,
className,
...props
}: PropertyGalleryProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const [index, setIndex] = React.useState(0);
const current = photos[index] ?? photos[0];
const priceLabel = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(price),
[price],
);
return (
<div
data-slot="property-gallery"
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="relative aspect-[16/10] overflow-hidden bg-muted">
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={index}
initial={animate ? { opacity: 0, filter: "blur(4px)" } : false}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={animate ? { opacity: 0, filter: "blur(4px)" } : undefined}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-0"
>
{current?.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={current.src}
alt={current.alt ?? `${address} photo ${index + 1}`}
className="size-full object-cover"
/>
) : (
<Placeholder hue={current?.hue} />
)}
</motion.div>
</AnimatePresence>
<span className="absolute bottom-3 left-3 rounded-full bg-background/90 px-2.5 py-1 text-sm font-semibold tabular-nums shadow-border backdrop-blur">
{priceLabel}
</span>
</div>
<div className="flex gap-2 p-3">
{photos.slice(0, 3).map((photo, i) => (
<button
key={i}
type="button"
aria-label={`View photo ${i + 1}`}
aria-current={i === index}
onClick={() => setIndex(i)}
className={cn(
"relative h-14 flex-1 overflow-hidden rounded-md outline-none ring-ring transition-[box-shadow] duration-150 focus-visible:ring-2",
i === index ? "shadow-border-hover" : "opacity-70 hover:opacity-100",
)}
>
{photo.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photo.src} alt="" className="size-full object-cover" />
) : (
<Placeholder hue={photo.hue} />
)}
</button>
))}
{/* Map-pin thumbnail */}
<div
aria-label="Location map"
role="img"
className="preview-bg-grid relative h-14 flex-1 overflow-hidden rounded-md bg-secondary shadow-border"
>
<MapPin
aria-hidden
className="absolute size-4 -translate-x-1/2 -translate-y-full fill-primary/20 text-primary"
style={{ left: `${pin.x * 100}%`, top: `${pin.y * 100}%` }}
/>
</div>
</div>
<div className="border-t border-border px-4 pb-4 pt-3">
<p className="truncate text-sm font-medium">{address}</p>
<p className="flex items-center gap-1 truncate text-sm text-muted-foreground">
<MapPin aria-hidden className="size-3.5 shrink-0" />
{location}
</p>
<div className="mt-3 flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<BedDouble className="size-4 shrink-0" />
<span className="tabular-nums">{beds}</span>
<span className="sr-only">bedrooms</span>
</span>
<span className="flex items-center gap-1.5">
<Bath className="size-4 shrink-0" />
<span className="tabular-nums">{baths}</span>
<span className="sr-only">bathrooms</span>
</span>
<span className="flex items-center gap-1.5">
<Maximize className="size-4 shrink-0" />
<span className="tabular-nums">
{new Intl.NumberFormat("en-US").format(sqft)}
</span>
<span>sqft</span>
</span>
</div>
</div>
</div>
);
}