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, 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;
}
const PLACEHOLDER_TINTS = [
"oklch(0.72 0.05 250)",
"oklch(0.7 0.05 160)",
"oklch(0.74 0.05 85)",
"oklch(0.7 0.05 30)",
];
/**
* A property listing card: a photo with an inline dot pager (click a dot or
* tap the left/right halves to page), a save/heart toggle that fills and
* settles, price, address, and a key-stats row. Photos crossfade with a
* small blur; reduced motion swaps them 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}`}
className="size-full object-cover"
/>
) : (
<div
aria-hidden
className="size-full"
style={{
background: `linear-gradient(135deg, ${
PLACEHOLDER_TINTS[index % PLACEHOLDER_TINTS.length]
}, oklch(0.4 0.03 250))`,
}}
/>
)}
</motion.div>
</AnimatePresence>
{/* Paging hit zones — left/right halves. */}
{count > 1 && (
<div aria-hidden className="absolute inset-0 flex">
<button
type="button"
tabIndex={-1}
aria-label="Previous photo"
className="h-full flex-1 cursor-default outline-none"
onClick={() => paginate(index - 1)}
/>
<button
type="button"
tabIndex={-1}
aria-label="Next photo"
className="h-full flex-1 cursor-default outline-none"
onClick={() => paginate(index + 1)}
/>
</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"
>
<Heart
className={cn(
"size-4 transition-[color,fill,scale] duration-150 ease-out",
isSaved
? "scale-110 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 outline-none after:absolute after:size-6 focus-visible:[&>span]:ring-2 focus-visible:[&>span]:ring-background"
>
<span
className={cn(
"block rounded-full bg-background transition-[width,opacity] duration-200 ease-out",
i === index ? "h-1.5 w-4 opacity-100" : "size-1.5 opacity-60",
)}
/>
</button>
))}
</div>
)}
</div>
<div className="p-4">
<div className="flex items-baseline justify-between gap-3">
<p className="text-lg font-semibold tabular-nums">{priceLabel}</p>
</div>
<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>
);
}