Blog//7 min read
Property listing card in React with saved homes
Build a React property card with a status badge, a saved homes heart, Intl price formatting, key facts that count up and grid and skeleton states.
MSMarco Schulz, founder of Facade UI
The listing card is the most repeated element on any real estate site. A search results page renders it twenty times, a home page six times, an agent page four. Whatever is wrong with it is wrong everywhere at once, which is why I spend more time on this one component than on any hero. The Property card in Facade is free and MIT licensed, and this article walks through its anatomy and the decisions behind each part.
Card anatomy
Top to bottom, a listing card has five zones:
- The photo, with a fixed aspect ratio so a grid of cards lines up
- A status badge in the photo's top left corner
- A save heart in the top right
- Price, then address and city
- Beds, baths and square footage in a row with icons
The order is the order buyers read. Photo first, because they are deciding whether to look at all. Price second, because it is the filter. Address third, because it tells them the neighborhood. The facts row last, because it confirms or kills the interest the first three created.
The whole card is a motion.article. On hover it lifts by two pixels and the photo scales up by four percent inside its clipped container, both of which are skipped when the visitor prefers reduced motion. The address is the link: when you pass href, it renders as an anchor with a pseudo element that covers the entire card, so the full card is clickable while the heart button stays a separate, nested-free control on top.
The status badge
Status is a small union type, not a free string:
export type PropertyStatus = "for-sale" | "pending" | "new";Each value maps to a label and a style. "For sale" and "Pending" sit on a translucent background over the photo. "New" uses the brand color, because new listings are the ones a returning visitor is scanning for. Keeping the union small is deliberate; the moment status accepts any string, the grid ends up with "Active", "ACTIVE" and "active" rendering three different ways.
The saved heart
The heart is a <button> with aria-pressed and a label that flips between "Save home" and "Remove from saved homes." When it toggles, the icon re-mounts with a spring from a smaller scale, and a ring expands outward and fades, the way a like button does on a social app. The ring only renders when the home is being saved and the visitor has not asked for reduced motion.
State is simple: defaultFavorite seeds it and onFavoriteChange reports every toggle. The card does not persist anything itself. Persistence belongs to the page, because the page knows whether the visitor is logged in, what the storage key should be and whether saved homes sync to an account.
For an anonymous visitor, localStorage is enough:
"use client";
import { useEffect, useState } from "react";
import { PropertyCard } from "@/components/facade/property-card";
const KEY = "saved-homes";
function useSavedHomes() {
const [saved, setSaved] = useState<string[]>([]);
useEffect(() => {
try {
setSaved(JSON.parse(localStorage.getItem(KEY) ?? "[]"));
} catch {}
}, []);
const toggle = (id: string, on: boolean) =>
setSaved((prev) => {
const next = on ? [...new Set([...prev, id])] : prev.filter((x) => x !== id);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {}
return next;
});
return { saved, toggle };
}
export function ListingCard({ id, ...listing }: { id: string; price: number; address: string; city: string; beds: number; baths: number; sqft: number; src?: string }) {
const { saved, toggle } = useSavedHomes();
return (
<PropertyCard
{...listing}
status="new"
href={`/homes/${id}`}
defaultFavorite={saved.includes(id)}
onFavoriteChange={(on) => toggle(id, on)}
/>
);
}Read storage in an effect, not during render, or the server and the client disagree on the first paint and React warns about a hydration mismatch. Wrap the storage calls in try because private browsing modes and some embedded browsers throw on access.
Price and area formatting with Intl
Never build a price string by hand. "$" + price gives you $1250000, which a buyer has to count the zeros of. The card uses Intl.NumberFormat with style: "currency" and zero fraction digits, so the output is $1,250,000. Square footage goes through toLocaleString("en-US") for the thousands separator. For rentals, priceSuffix="/mo" appends a muted suffix after the formatted price.
The same thinking applies to the facts strip on a listing detail page. The Key facts component takes an array of facts and counts each number up when it scrolls into view. It handles the two cases that trip up a naive counter: compact notation, so a list price counts up as $1.25M instead of a seven digit blur, and noGrouping for a year built, so 2019 does not render as 2,019. For the year it also starts the count from 97 percent of the target instead of zero, because watching 0 climb to 2019 is silly.
import { KeyFacts, type KeyFact } from "@/components/facade/key-facts";
import { DollarSign, BedDouble, Bath, Ruler, Calendar } from "lucide-react";
const facts: KeyFact[] = [
{ label: "List price", value: 1250000, icon: DollarSign, prefix: "$", compact: true, decimals: 2 },
{ label: "Bedrooms", value: 4, icon: BedDouble },
{ label: "Bathrooms", value: 3.5, icon: Bath, decimals: 1 },
{ label: "Living area", value: 2840, icon: Ruler, suffix: " sqft" },
{ label: "Year built", value: 2019, icon: Calendar, noGrouping: true },
];
<KeyFacts facts={facts} duration={1.4} />Agent cards and brokerage headers often need a rating next to the listing. The Star rating component renders fractional stars by clipping the filled layer with clip-path, so 4.8 shows four full stars and most of a fifth. It exposes one accessible label, "Rated 4.8 out of 5, based on 312 reviews", and hides the individual star icons from assistive tech, so a screen reader hears one sentence instead of five images.
Grid and skeleton states
A card is only half the job; the grid around it is the other half. Three rules I follow:
- Fixed aspect ratio on the photo. The card uses a 3 by 2 frame, and when no
srcis passed it renders an illustrated placeholder in one of three variants (modern,bungalow,tower) so a grid never shows a broken image icon. - Equal height cards through CSS grid, not through padding tricks. Long addresses truncate with an ellipsis instead of wrapping and breaking the row.
- A skeleton while listings load. Render the same number of cards you expect, as muted rectangles with the photo's aspect ratio and three text bars, so the layout does not jump when data arrives.
Here is the skeleton I use, sized to match the card:
export function PropertyCardSkeleton() {
return (
<div className="animate-pulse overflow-hidden rounded-[22px] bg-muted">
<div className="aspect-[3/2] bg-foreground/[0.06]" />
<div className="space-y-3 p-4">
<div className="h-5 w-32 rounded bg-foreground/[0.08]" />
<div className="h-4 w-48 rounded bg-foreground/[0.06]" />
<div className="h-4 w-40 rounded bg-foreground/[0.06]" />
</div>
</div>
);
}When the grid needs filtering, sorting, saved homes and load more pagination in one piece, the Property listing grid block in Facade wraps the card with all of that. For a single card, install the free component:
npx shadcn@latest add https://facadeui.com/r/property-card.jsonOn a brokerage site, the card appears first in the listing grid under the hero search and again in the "recently sold" strip near the bottom. Same component, two data sources, one place to fix a bug.
FAQ
Can the card show a photo carousel?
Not out of the box. The card takes one src. For a carousel, swap the image element in the installed source for your slider; the badge and heart are positioned absolutely over the photo frame and will sit on top of whatever you put there.
How do I format prices for other currencies?
The installed source creates its formatter with en-US and USD. Change the locale and currency in that one Intl.NumberFormat call, or lift the formatter into a prop if the site lists in several currencies.
Why not let the card persist saved homes itself?
Because a component cannot know the right place to store them. Logged in users expect saved homes to follow them across devices, anonymous visitors expect them to survive a refresh, and some sites want neither. The card reports the toggle through onFavoriteChange and the page decides.
Components in this article
- Property cardFreeListing card with status badge, saved-home heart, price, address and beds, baths and square footage.
- Key factsFreeProperty key facts row with icons and numbers that count up when scrolled into view.
- Star ratingFreeAnimated fractional star rating with review count, plus an accessible interactive input mode.