Property Card
ProReal-estate listing card — pageable photo strip with the real gallery count, status ribbon, price with derived price-per-area, a beds/baths/parking/area row that keeps zero and unknown apart, amenity chips, save and compare toggles and an agent line. Grid, list and wide layouts.
Complexity tier: Primitive Presents a value: held to semantic markup and contrast, with its right-to-left and print gaps recorded and the right-to-left debt allowed only to shrink.
import { SvPropertyCardComponent } from '@styloviz-pro/commerce';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
A rental
ProA studio, and a plot with no rooms at all
Pro`0` renders as "Studio"; `null` renders nothing. Never conflate them.
Map-list row
ProSold — the actions are removed, not disabled
ProWhen to use
- Property search results, saved shortlists, agent portfolios and "similar homes" rails.
- Anywhere the price needs a per-unit-area figure beside it to be comparable.
- For a generic product with a rating and an add-to-cart, use the Product Card instead.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| titlerequired | string | — | Headline — usually the street address. |
| location | string | '' | Secondary line — neighbourhood, city, postcode. |
| href | string | '' | Link target. Renders the title as a stretched link when set. |
| pricerequired | number | — | Asking price or rent, in `currency`. |
| compareAt | number | null | null | Previous asking price, to show a reduction. |
| currency | string | 'USD' | ISO 4217 currency code. |
| locale | string | 'en-US' | BCP-47 locale. |
| minorUnits | boolean | false | Treat prices as minor units. |
| pricePeriod | PropertyPricePeriod | '' | Rent period. `''` (the default) means this is a sale price. |
| fractionDigits | number | null | 0 | Decimal places on the price. `null` uses the currency's own convention. Defaults to `0` here, unlike `sv-price`, which defaults to the currency convention. Property prices are not quoted to the cent in any market: `$1,180,000.00` reads as a spreadsheet export rather than an asking price, and the two trailing zeroes are the widest thing on the card. Pass `null` to get the currency default back. |
| beds | number | null | null | Bedrooms. `0` is a studio; `null` means nobody said. |
| baths | number | null | null | Bathrooms. `null` means nobody said. |
| parking | number | null | null | Parking spaces. `0` renders as "No parking"; `null` renders nothing. |
| area | number | null | null | Internal floor area in `areaUnit`. `null` means nobody measured. |
| lotArea | number | null | null | Plot area in the same unit. `null` means there is none, or none recorded. It earns its place on a card that already shows floor area because of land: a building plot has no internal area at all, so without this input the card for one renders a price and an empty stat row. It is also the figure that separates two otherwise identical houses. Shown after the floor area, never instead of it — they are different measurements and a card that silently substituted one for the other would make a 1,200 sq ft house on a 9,000 sq ft plot look enormous. |
| areaUnit | PropertyAreaUnit | 'sqft' | The unit `area` and `lotArea` are GIVEN in. |
| displayAreaUnit | PropertyAreaUnit | null | null | The unit to DISPLAY in. Converts; it does not merely relabel. RELABELLING WITHOUT CONVERTING IS THE WHOLE DEFECT. `areaUnit` says what the number IS, and a card that swapped the suffix would turn 1,800 sq ft into "1,800 m²" — a house four times its real size, stated with total confidence. Leave this `null` to display in the unit the data arrived in. |
| updatedAt | string | '' | When this listing was last confirmed with its source, as `YYYY-MM-DD` or an ISO timestamp. A SOLD LISTING SHOWING AS AVAILABLE IS THE DEFINING FAILURE OF A PROPERTY CARD, and a status badge alone cannot prevent it: the badge is only ever as true as the last sync, and it never says when that was. A reader who can see the date can judge the badge; one who cannot has to trust it absolutely. |
| staleAfterDays | number | 30 | How many days before a listing is called out as possibly stale. |
| source | string | '' | Where this listing came from — the portal, board or agency. In several markets naming the source is a LEGAL requirement rather than a courtesy, and a card with nowhere to put it cannot be shipped there at all. Rendered with the disclaimer beneath the card. |
| sourceUrl | string | '' | Optional link to the listing at its source. |
| disclaimer | string | '' | Required legal text, rendered verbatim beneath the card. |
| features | readonly PropertyFeature[] | [] | Amenity chips under the stat row. |
| featureLimit | number | 3 | How many chips to show before "+n more". |
| media | readonly PropertyMedia[] | [] | The photos this card can actually page through. |
| mediaCount | number | 0 | The true size of the gallery, when `media` is a trimmed subset. `0` (the default) means `media` is all of it. |
| hasTour | boolean | false | Show the 360°-tour marker. |
| tourLabel | string | '360° tour' | Wording for the tour marker. |
| status | PropertyStatus | 'for-sale' | Lifecycle state. |
| statusLabel | string | '' | Override the status wording, e.g. a localised label. |
| variant | PropertyCardVariant | 'grid' | Layout. |
| listedLabel | string | '' | Small line under the actions — "Listed 3 days ago". |
| agentName | string | '' | Agent or agency name. |
| agentAvatar | string | '' | Agent avatar URL. |
| hoverable | boolean | true | Lift the card on hover. |
| showSave | boolean | true | Show the save (heart) toggle. |
| showCompare | boolean | true | Show the compare toggle. |
| tourInteractive | boolean | false | Make the 360° tour marker a button rather than a label. Off by default, because a control that refuses every click is worse than a plain badge. Turn it on and bind `(tourOpen)`; the card then stops the click reaching the stretched card link, so opening the tour does not also navigate to the property. |
| linkMode | 'browser' | 'host' | 'browser' | Who handles a plain left-click on the card when `href` is set. `'browser'` follows the link, which is a full page load. `'host'` cancels the plain left-click and emits `open` instead, leaving every modified click and every middle-click to the browser — which is what a router-driven app wants: client-side navigation on a normal click, a real new tab on ⌘-click. Bind `(open)` when using `'host'`, or the card does nothing. |
| compareDisabled | boolean | false | The comparison is full, so this card cannot be added to it. A TRAY WITH A LIMIT NEEDS THIS, AND EVERY TRAY HAS A LIMIT. Without it the host's only move is to refuse the click after the fact: the card optimistically flips `compared`, the host's binding flips it back on the next change-detection pass, and the reader sees the icon blink and nothing else. Told in advance, the button is properly disabled, says why on hover, and is skipped by the keyboard — which is the difference between a limit and a bug. A card ALREADY in the tray stays enabled whatever this says, because removing is how the reader makes room. |
| compareFullHint | string | 'The comparison is full' | Tooltip on the disabled compare button. |
| saved | boolean (two-way) | false | Saved to a shortlist (two-way: `[(saved)]`). |
| compared | boolean (two-way) | false | Added to the compare tray (two-way: `[(compared)]`). |
Outputs
| Event | Payload type | Description |
|---|---|---|
| open | void | The card body was activated. Fired instead of navigating when `href` is empty. There is deliberately no `savedChange` / `comparedChange` output declared here: `model()` already emits one per two-way input, and declaring a second output with the same job is how a host ends up subscribed to one of them and puzzled by the other. |
| contactAgent | void | The agent line was activated. |
| tourOpen | void | The 360° tour marker was activated. Only reachable when `tourInteractive` is set — see the note beside that input. |
| mediaChange | number | The visible photo changed, with its zero-based index. |
| savedChange | boolean | Emits the new `saved` whenever it changes — the output half of `[(saved)]`. |
| comparedChange | boolean | Emits the new `compared` whenever it changes — the output half of `[(compared)]`. |
Customization & theming
- Grid, list and wide layouts.
- Seven lifecycle statuses with their own ribbon colours, overridable via `statusLabel`.
- Amenity chips capped by `featureLimit` with a "+n more" remainder.
- Optional 360°-tour marker, agent line and "listed" label.
Best practices
- Do: Pass `null` for a figure nobody recorded and `0` for a figure that is genuinely zero — a studio is 0 bedrooms, a building plot has none.
- Do: Set `mediaCount` when the feed sends a trimmed photo subset, so the counter states the real gallery size.
- Do: Leave `fractionDigits` at 0 — property prices are never quoted to the cent.
- Don't: Do not coerce a missing area to zero to get a per-area figure; the card omits the line for a reason.
- Tip: A `sold` or `let` status removes the save and compare controls rather than disabling them.
Accessibility
- Photo paging exposes labelled previous/next buttons and a polite live region announcing "Photo 2 of 24" — the visible counter sits inside an aria-hidden cluster.
- The stat row is a `<dl>` whose `<dt>` carries the written label ("3 bedrooms"), so the figures mean something without the icons.
- Save and compare are toggle buttons with `aria-pressed` and labels naming the property.
- Alt text is required on every photo rather than optional.
Feedback on Property Card
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.