PropTech

Property Gallery

A listing gallery with a crossfading main photo, a thumbnail strip including a map-pin location tile, and key details.

Install

npx shadcn@latest add @paragon/property-gallery

property-gallery.tsx

"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>
  );
}