Browse the library
Components / Cards
Property card
FreeListing card with status badge, saved-home heart, price, address and beds, baths and square footage.
components/facade/property-card.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bath, BedDouble, Heart, MapPin, Ruler } from "lucide-react";
import { cn } from "@/lib/utils";
export type PropertyStatus = "for-sale" | "pending" | "new";
export interface PropertyCardProps {
/** Optional photo URL. When omitted, an illustrated architectural placeholder renders. */
src?: string;
alt?: string;
status?: PropertyStatus;
price?: number;
/** Optional suffix such as "/mo" for rentals. */
priceSuffix?: string;
address?: string;
city?: string;
beds?: number;
baths?: number;
sqft?: number;
/** Placeholder composition when no src is set. */
placeholder?: "modern" | "bungalow" | "tower";
href?: string;
defaultFavorite?: boolean;
onFavoriteChange?: (favorite: boolean) => void;
className?: string;
}
const statusCopy: Record<PropertyStatus, { label: string; className: string; dot: string }> = {
"for-sale": {
label: "For sale",
className: "bg-background/90 text-foreground",
dot: "bg-success",
},
pending: {
label: "Pending",
className: "bg-background/90 text-foreground",
dot: "bg-warning",
},
new: {
label: "New",
className: "bg-brand text-brand-foreground",
dot: "bg-brand-foreground",
},
};
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
function PropertyPlaceholder({ variant }: { variant: "modern" | "bungalow" | "tower" }) {
const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
return (
<svg
viewBox="0 0 400 260"
preserveAspectRatio="xMidYMid slice"
className="h-full w-full text-foreground"
aria-hidden="true"
>
<defs>
<linearGradient id={`sky-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--brand)" stopOpacity="0.28" />
<stop offset="0.65" stopColor="var(--brand)" stopOpacity="0.06" />
<stop offset="1" stopColor="var(--muted)" stopOpacity="1" />
</linearGradient>
<radialGradient id={`sun-${id}`} cx="0.78" cy="0.22" r="0.35">
<stop offset="0" stopColor="var(--brand)" stopOpacity="0.55" />
<stop offset="1" stopColor="var(--brand)" stopOpacity="0" />
</radialGradient>
<linearGradient id={`glass-${id}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="var(--brand)" stopOpacity="0.55" />
<stop offset="1" stopColor="var(--brand)" stopOpacity="0.15" />
</linearGradient>
</defs>
<rect width="400" height="260" fill="var(--muted)" />
<rect width="400" height="260" fill={`url(#sky-${id})`} />
<rect width="400" height="260" fill={`url(#sun-${id})`} />
{/* distant hills */}
<path d="M0 190 Q 80 160 160 182 T 320 172 T 400 180 V260 H0Z" fill="currentColor" opacity="0.06" />
{variant === "modern" && (
<g>
<rect x="88" y="118" width="150" height="86" fill="var(--card)" />
<rect x="88" y="118" width="150" height="86" fill="currentColor" opacity="0.05" />
<rect x="196" y="92" width="122" height="112" fill="var(--card)" />
<rect x="188" y="86" width="138" height="8" fill="currentColor" opacity="0.75" />
<rect x="80" y="112" width="166" height="7" fill="currentColor" opacity="0.65" />
<rect x="208" y="106" width="98" height="64" fill={`url(#glass-${id})`} />
<path d="M257 106 V170 M208 138 H306" stroke="var(--card)" strokeWidth="2.5" />
<rect x="102" y="134" width="56" height="40" fill={`url(#glass-${id})`} />
<rect x="172" y="146" width="18" height="58" fill="currentColor" opacity="0.7" />
<rect x="96" y="186" width="70" height="18" fill="currentColor" opacity="0.08" />
</g>
)}
{variant === "bungalow" && (
<g>
<path d="M92 132 L200 76 L308 132 Z" fill="currentColor" opacity="0.72" />
<rect x="108" y="130" width="184" height="74" fill="var(--card)" />
<rect x="108" y="130" width="184" height="74" fill="currentColor" opacity="0.04" />
<rect x="124" y="146" width="44" height="34" fill={`url(#glass-${id})`} />
<rect x="232" y="146" width="44" height="34" fill={`url(#glass-${id})`} />
<rect x="186" y="150" width="28" height="54" fill="currentColor" opacity="0.7" />
<path d="M146 146 V180 M254 146 V180" stroke="var(--card)" strokeWidth="2" />
<rect x="100" y="196" width="200" height="8" fill="currentColor" opacity="0.12" />
<circle cx="66" cy="178" r="22" fill="currentColor" opacity="0.12" />
<circle cx="336" cy="174" r="26" fill="currentColor" opacity="0.1" />
</g>
)}
{variant === "tower" && (
<g>
<rect x="150" y="40" width="100" height="164" fill="var(--card)" />
<rect x="150" y="40" width="100" height="164" fill="currentColor" opacity="0.05" />
{Array.from({ length: 7 }).map((_, row) => (
<g key={row}>
<rect x="160" y={52 + row * 20} width="38" height="12" fill={`url(#glass-${id})`} />
<rect x="202" y={52 + row * 20} width="38" height="12" fill={`url(#glass-${id})`} />
<rect x="150" y={66 + row * 20} width="100" height="2" fill="currentColor" opacity="0.4" />
</g>
))}
<rect x="70" y="120" width="70" height="84" fill="currentColor" opacity="0.1" />
<rect x="262" y="100" width="72" height="104" fill="currentColor" opacity="0.08" />
</g>
)}
<rect x="0" y="204" width="400" height="56" fill="currentColor" opacity="0.07" />
<path d="M0 204 H400" stroke="currentColor" strokeOpacity="0.12" />
</svg>
);
}
export function PropertyCard({
src,
alt = "Property photo",
status = "for-sale",
price = 685000,
priceSuffix,
address = "2418 Travis Heights Blvd",
city = "Austin, TX 78704",
beds = 3,
baths = 2.5,
sqft = 2140,
placeholder = "modern",
href,
defaultFavorite = false,
onFavoriteChange,
className,
}: PropertyCardProps) {
const reduce = useReducedMotion();
const [favorite, setFavorite] = React.useState(defaultFavorite);
const s = statusCopy[status];
const toggle = () => {
const next = !favorite;
setFavorite(next);
onFavoriteChange?.(next);
};
const Title = href ? "a" : "span";
return (
<motion.article
className={cn(
"group relative flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-sm transition-shadow duration-300 hover:shadow-xl hover:shadow-foreground/5",
className,
)}
whileHover={reduce ? undefined : { y: -4 }}
transition={{ type: "spring", stiffness: 400, damping: 32 }}
>
<div className="relative aspect-[3/2] overflow-hidden bg-muted">
<div className="h-full w-full transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.06]">
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className="h-full w-full object-cover" loading="lazy" />
) : (
<PropertyPlaceholder variant={placeholder} />
)}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-foreground/25 to-transparent" />
<span
className={cn(
"absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm backdrop-blur",
s.className,
)}
>
<span className={cn("size-1.5 rounded-full", s.dot)} />
{s.label}
</span>
<button
type="button"
onClick={toggle}
aria-pressed={favorite}
aria-label={favorite ? "Remove from saved homes" : "Save home"}
className="absolute right-3 top-3 z-10 grid size-9 place-items-center rounded-full bg-background/90 text-foreground shadow-sm backdrop-blur transition-colors hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<motion.span
key={favorite ? "on" : "off"}
initial={reduce ? false : { scale: favorite ? 0.4 : 0.85 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 18 }}
className="relative grid place-items-center"
>
<Heart
aria-hidden="true"
className={cn(
"size-4 transition-colors",
favorite ? "fill-destructive text-destructive" : "text-foreground",
)}
/>
</motion.span>
<AnimatePresence>
{favorite && !reduce && (
<motion.span
key="burst"
className="pointer-events-none absolute inset-0 rounded-full border-2 border-destructive/60"
initial={{ scale: 0.6, opacity: 1 }}
animate={{ scale: 1.6, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
/>
)}
</AnimatePresence>
</button>
</div>
<div className="flex flex-1 flex-col gap-3 p-4 sm:p-5">
<div className="flex items-baseline gap-1">
<span className="font-display text-2xl font-semibold tracking-tight tabular-nums">
{usd.format(price)}
</span>
{priceSuffix && <span className="text-sm text-muted-foreground">{priceSuffix}</span>}
</div>
<div className="min-w-0">
<Title
{...(href ? { href } : {})}
className={cn(
"block truncate font-medium",
href &&
"rounded-sm after:absolute after:inset-0 after:content-[''] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{address}
</Title>
<p className="mt-0.5 flex items-center gap-1 truncate text-sm text-muted-foreground">
<MapPin className="size-3.5 shrink-0" aria-hidden="true" />
{city}
</p>
</div>
<dl className="mt-auto grid grid-cols-3 divide-x divide-border border-t border-border pt-3 text-sm">
<div className="flex items-center gap-1.5 pr-2">
<BedDouble className="size-4 text-muted-foreground" aria-hidden="true" />
<dt className="sr-only">Bedrooms</dt>
<dd>
<span className="font-medium tabular-nums">{beds}</span>{" "}
<span className="text-muted-foreground">bd</span>
</dd>
</div>
<div className="flex items-center justify-center gap-1.5 px-2">
<Bath className="size-4 text-muted-foreground" aria-hidden="true" />
<dt className="sr-only">Bathrooms</dt>
<dd>
<span className="font-medium tabular-nums">{baths}</span>{" "}
<span className="text-muted-foreground">ba</span>
</dd>
</div>
<div className="flex items-center justify-end gap-1.5 pl-2">
<Ruler className="size-4 text-muted-foreground" aria-hidden="true" />
<dt className="sr-only">Square feet</dt>
<dd>
<span className="font-medium tabular-nums">{sqft.toLocaleString("en-US")}</span>{" "}
<span className="text-muted-foreground">sqft</span>
</dd>
</div>
</dl>
</div>
</motion.article>
);
}
export default PropertyCard;app/page.tsx
import { PropertyCard } from "@/components/facade/property-card";
export default function Demo() {
return (
<div className="grid w-full max-w-[720px] gap-5 sm:grid-cols-2">
<PropertyCard
status="new"
price={685000}
address="2418 Travis Heights Blvd"
city="Austin, TX 78704"
beds={3}
baths={2.5}
sqft={2140}
placeholder="modern"
defaultFavorite
/>
<PropertyCard
status="pending"
price={1249000}
address="3150 Bluebird Ln"
city="Denver, CO 80211"
beds={4}
baths={3}
sqft={2865}
placeholder="bungalow"
/>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/property-card.jsonUsage
import { PropertyCard } from "@/components/facade/property-card";
export default function Page() {
return <PropertyCard />;
}Props
PropertyCardProps is exported, so you can extend or wrap it. Every prop has a default; <PropertyCard /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| src | string | Optional photo URL. When omitted, an illustrated architectural placeholder renders. |
| alt | string | |
| status | PropertyStatus | |
| price | number | |
| priceSuffix | string | Optional suffix such as "/mo" for rentals. |
| address | string | |
| city | string | |
| beds | number | |
| baths | number | |
| sqft | number | |
| placeholder | "modern" | "bungalow" | "tower" | Placeholder composition when no src is set. |
| href | string | |
| defaultFavorite | boolean | |
| onFavoriteChange | (favorite: boolean) => void | |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade property card to this page and adapt the copy to our business.