# Property card

Listing card with status badge, saved-home heart, price, address and beds, baths and square footage.

- Type: component
- Category: Cards
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/property-card.json
```

## Usage

```tsx
import { PropertyCard } from "@/components/facade/property-card";

export default function Page() {
  return <PropertyCard />;
}
```

## Source

```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;
```
