Listing Card
Cards

Listing Card

A property listing card with an inline photo-dot pager, price, a save/heart toggle, and a key-stats row.

Install

npx shadcn@latest add @paragon/listing-card

listing-card.tsx

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