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