A property listing card with an inline photo-dot pager, price, a save/heart toggle, and a key-stats row.
npx shadcn@latest add @paragon/listing-card"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
Bath,
BedDouble,
ChevronLeft,
ChevronRight,
Heart,
Maximize,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface ListingPhoto {
/** Image URL. When omitted a tinted placeholder is shown. */
src?: string;
alt?: string;
}
export interface ListingCardProps
extends Omit<React.ComponentProps<"article">, "onToggle"> {
address: string;
/** Secondary line, e.g. neighborhood or city/state. */
location?: string;
/** Numeric price; formatted as currency. */
price: number;
/** Short status pill, e.g. "New" or "Price cut". */
status?: string;
beds: number;
baths: number;
/** Interior area in sqft. */
sqft: number;
photos: ListingPhoto[];
/** Controlled saved state. */
saved?: boolean;
/** Uncontrolled initial saved state. */
defaultSaved?: boolean;
onToggleSaved?: (saved: boolean) => void;
/** Disables the pager crossfade. */
static?: boolean;
}
/**
* A CSS-composed listing "photo" — no network, no flat block. Each scene is a
* layered set of muted gradients plus a few positioned shapes (roof, windows,
* horizon, foliage) and a vignette, so the placeholder reads as a real
* architectural photo. Palette stays restrained (slate / indigo / teal /
* warm-sand), never garish. Deterministic per index — safe for hydration.
*/
function ListingScene({ index }: { index: number }) {
const scene = index % 4;
return (
<div
aria-hidden
className="absolute inset-0 size-full overflow-hidden"
style={{
background:
scene === 0
? // Exterior at dusk — deep sky over a lit facade.
"linear-gradient(180deg, oklch(0.42 0.06 265) 0%, oklch(0.58 0.06 255) 34%, oklch(0.74 0.05 60) 52%, oklch(0.5 0.045 145) 52%, oklch(0.36 0.04 150) 100%)"
: scene === 1
? // Open-plan kitchen — warm daylight on sand cabinetry.
"linear-gradient(175deg, oklch(0.93 0.02 85) 0%, oklch(0.86 0.03 75) 46%, oklch(0.7 0.045 60) 60%, oklch(0.58 0.05 55) 100%)"
: scene === 2
? // Primary bedroom — soft dawn light, muted rose/slate.
"linear-gradient(170deg, oklch(0.9 0.02 40) 0%, oklch(0.83 0.03 30) 52%, oklch(0.64 0.045 300) 52%, oklch(0.5 0.05 295) 100%)"
: // Backyard deck — clear sky over lawn and timber.
"linear-gradient(180deg, oklch(0.78 0.06 235) 0%, oklch(0.88 0.04 210) 40%, oklch(0.66 0.09 145) 55%, oklch(0.5 0.09 140) 74%, oklch(0.44 0.06 55) 74%, oklch(0.36 0.05 50) 100%)",
}}
>
{scene === 0 && (
<>
{/* Sun glow near the horizon. */}
<div
className="absolute inset-x-0 top-[42%] h-24"
style={{
background:
"radial-gradient(60% 120% at 72% 0%, oklch(0.85 0.08 70 / 0.55), transparent 70%)",
}}
/>
{/* House block + pitched roof, sitting on the ground line. */}
<div className="absolute bottom-0 left-[14%] h-[42%] w-[52%] bg-[oklch(0.3_0.03_260)]" />
<div
className="absolute bottom-[42%] left-[8%] h-[16%] w-[64%]"
style={{
background: "oklch(0.26 0.03 265)",
clipPath: "polygon(12% 100%, 50% 0%, 88% 100%)",
}}
/>
{/* Warm lit windows. */}
<div className="absolute bottom-[14%] left-[22%] size-[9%] rounded-[2px] bg-[oklch(0.86_0.09_75)]" />
<div className="absolute bottom-[14%] left-[44%] size-[9%] rounded-[2px] bg-[oklch(0.86_0.09_75)]" />
</>
)}
{scene === 1 && (
<>
{/* Window light wash from the left. */}
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(115deg, oklch(1 0 0 / 0.35) 0%, transparent 38%)",
}}
/>
{/* Upper cabinet band + counter slab. */}
<div className="absolute top-[20%] right-0 left-0 h-[14%] bg-[oklch(0.72_0.04_70)]" />
<div className="absolute top-[58%] right-0 left-0 h-[7%] bg-[oklch(0.82_0.02_250)]" />
{/* Base cabinets with seams. */}
<div
className="absolute top-[65%] right-0 left-0 bottom-0"
style={{
background:
"repeating-linear-gradient(90deg, oklch(0.6 0.05 60) 0 24%, oklch(0.54 0.05 58) 24% 25%)",
}}
/>
{/* Pendant lights. */}
<div className="absolute top-[34%] left-[30%] h-[10%] w-px bg-[oklch(0.4_0.02_70)]" />
<div className="absolute top-[44%] left-[28%] size-[5%] rounded-full bg-[oklch(0.9_0.06_80)]" />
<div className="absolute top-[34%] left-[58%] h-[10%] w-px bg-[oklch(0.4_0.02_70)]" />
<div className="absolute top-[44%] left-[56%] size-[5%] rounded-full bg-[oklch(0.9_0.06_80)]" />
</>
)}
{scene === 2 && (
<>
{/* Soft light from a window, upper right. */}
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(70% 60% at 82% 8%, oklch(1 0 0 / 0.3), transparent 60%)",
}}
/>
{/* Headboard + bed block against the far wall. */}
<div className="absolute top-[30%] left-[16%] h-[22%] w-[68%] rounded-t-md bg-[oklch(0.56_0.045_300)]" />
<div className="absolute top-[52%] left-[8%] right-[8%] bottom-0 bg-[oklch(0.8_0.03_320)]" />
<div className="absolute top-[52%] left-[8%] right-[8%] h-[10%] bg-[oklch(0.88_0.02_320)]" />
{/* Two framed prints over the bed. */}
<div className="absolute top-[12%] left-[34%] h-[12%] w-[12%] rounded-[2px] border border-[oklch(0.5_0.03_30)] bg-[oklch(0.86_0.03_40)]" />
<div className="absolute top-[12%] left-[52%] h-[12%] w-[12%] rounded-[2px] border border-[oklch(0.5_0.03_30)] bg-[oklch(0.86_0.03_40)]" />
</>
)}
{scene === 3 && (
<>
{/* Sun haze. */}
<div
className="absolute inset-x-0 top-0 h-[40%]"
style={{
background:
"radial-gradient(50% 100% at 24% 0%, oklch(0.95 0.05 95 / 0.5), transparent 70%)",
}}
/>
{/* Tree canopies behind the fence line. */}
<div className="absolute top-[36%] left-[6%] size-[18%] rounded-full bg-[oklch(0.56_0.08_150)]" />
<div className="absolute top-[32%] left-[18%] size-[22%] rounded-full bg-[oklch(0.52_0.09_150)]" />
<div className="absolute top-[38%] right-[10%] size-[20%] rounded-full bg-[oklch(0.54_0.08_148)]" />
{/* Timber deck planks in the foreground. */}
<div
className="absolute right-0 bottom-0 left-0 h-[26%]"
style={{
background:
"repeating-linear-gradient(90deg, oklch(0.5 0.06 55) 0 8%, oklch(0.44 0.06 52) 8% 8.6%)",
}}
/>
</>
)}
{/* Shared photographic vignette for depth. */}
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(120% 120% at 50% 30%, transparent 55%, oklch(0 0 0 / 0.28) 100%)",
}}
/>
</div>
);
}
/**
* A property listing card: a photo with an inline dot pager (click a dot or
* tap the left/right halves to page; fine pointers get hover-revealed
* arrows), a save/heart toggle that swaps via the house icon-swap recipe,
* price, address, and a key-stats row. Photos crossfade with a small blur;
* the active pager dot stretches into a pill via a clip-path morph (never an
* animated width). Reduced motion swaps everything in place.
*/
export function ListingCard({
address,
location,
price,
status,
beds,
baths,
sqft,
photos,
saved,
defaultSaved = false,
onToggleSaved,
static: isStatic = false,
className,
...props
}: ListingCardProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const [index, setIndex] = React.useState(0);
const [internalSaved, setInternalSaved] = React.useState(defaultSaved);
const isSaved = saved ?? internalSaved;
const count = Math.max(photos.length, 1);
const current = photos[index] ?? photos[0];
const priceLabel = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(price),
[price],
);
const paginate = (next: number) =>
setIndex(((next % count) + count) % count);
const toggleSaved = () => {
const next = !isSaved;
if (saved === undefined) setInternalSaved(next);
onToggleSaved?.(next);
};
return (
<article
data-slot="listing-card"
className={cn(
"group/card w-full max-w-sm overflow-hidden rounded-xl bg-card shadow-border transition-shadow duration-150 hover:shadow-border-hover",
className,
)}
{...props}
>
<div className="relative aspect-[4/3] 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}`}
loading="lazy"
className="size-full object-cover"
/>
) : (
<ListingScene index={index} />
)}
</motion.div>
</AnimatePresence>
{/* Paging hit zones — left/right halves. Hidden from AT (the dots
are the accessible pager); fine pointers get hover arrows that
slide in from each edge. */}
{count > 1 && (
<div aria-hidden className="absolute inset-0 flex">
<button
type="button"
tabIndex={-1}
aria-label="Previous photo"
className="flex h-full flex-1 cursor-pointer items-center justify-start pl-2 outline-none"
onClick={() => paginate(index - 1)}
>
<span className="hidden size-7 -translate-x-1 items-center justify-center rounded-full bg-background/90 text-foreground opacity-0 shadow-border backdrop-blur transition-[opacity,translate] duration-150 ease-out group-hover/card:translate-x-0 group-hover/card:opacity-100 motion-reduce:translate-x-0 motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:flex">
<ChevronLeft className="size-4" />
</span>
</button>
<button
type="button"
tabIndex={-1}
aria-label="Next photo"
className="flex h-full flex-1 cursor-pointer items-center justify-end pr-2 outline-none"
onClick={() => paginate(index + 1)}
>
<span className="hidden size-7 translate-x-1 items-center justify-center rounded-full bg-background/90 text-foreground opacity-0 shadow-border backdrop-blur transition-[opacity,translate] duration-150 ease-out group-hover/card:translate-x-0 group-hover/card:opacity-100 motion-reduce:translate-x-0 motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:flex">
<ChevronRight className="size-4" />
</span>
</button>
</div>
)}
{status && (
<span className="absolute top-3 left-3 rounded-full bg-background/90 px-2 py-0.5 text-xs font-medium shadow-border backdrop-blur">
{status}
</span>
)}
<button
type="button"
aria-pressed={isSaved}
aria-label={isSaved ? "Remove from saved" : "Save listing"}
onClick={toggleSaved}
className="pressable absolute top-3 right-3 flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-border backdrop-blur outline-none transition-[scale,box-shadow] duration-150 hover:shadow-border-hover focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{animate ? (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={isSaved ? "saved" : "unsaved"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="flex"
>
<Heart
className={cn(
"size-4",
isSaved
? "fill-destructive text-destructive"
: "fill-transparent text-foreground",
)}
/>
</motion.span>
</AnimatePresence>
) : (
<Heart
className={cn(
"size-4 transition-[color,fill] duration-150 ease-out",
isSaved
? "fill-destructive text-destructive"
: "fill-transparent text-foreground",
)}
/>
)}
</button>
{count > 1 && (
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5">
{photos.map((_, i) => (
<button
key={i}
type="button"
aria-label={`Go to photo ${i + 1}`}
aria-current={i === index}
onClick={() => setIndex(i)}
className="relative flex size-4 items-center justify-center rounded-full outline-none after:absolute after:size-6 focus-visible:ring-2 focus-visible:ring-background"
>
{/* Fixed-size pill; the inactive state clips it down to a
6px dot so the dot↔pill morph animates clip-path, not
width. */}
<span
className={cn(
"block h-1.5 w-4 rounded-full bg-background transition-[clip-path,opacity] duration-200 ease-out motion-reduce:transition-none",
i === index
? "opacity-100 [clip-path:inset(0_round_9999px)]"
: "opacity-60 [clip-path:inset(0_5px_round_9999px)]",
)}
/>
</button>
))}
</div>
)}
</div>
<div className="p-4">
<p className="text-lg font-semibold tabular-nums">{priceLabel}</p>
<p className="mt-1 truncate text-sm font-medium">{address}</p>
{location && (
<p className="truncate text-sm text-muted-foreground">{location}</p>
)}
<div className="mt-3 flex items-center gap-4 border-t border-border pt-3 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>
</article>
);
}