ListingCard
A consumer-marketplace card composing photos (Carousel), title,
Rating, price, host info, and optional verified badge / favorite
toggle. Built for marketplace grids — search results, related
listings, a host's other stays.
Two variants share the same photo + price + title spine:
default— consumer-stay style: rating, host, distance, favorite heart. Marketplace search-result row.spec— product-spec style: flag pill, photo counter, spec grid, inline CTA on a dark footer. Premium / spec-driven inventory.
Distinct from EntityCard: EntityCard is dev-tool chrome
(service / owner / artifact). ListingCard is the consumer card.
ListingCard also emits schema.org JSON-LD by default — see the
AI / SEO surface section below.
Default#
The photo carousel loops by default — clicking past the last photo
wraps to the first. Pass loop={false} if you need stop-at-end
behavior (see Carousel → Loop for what's
going on under the hood).
On sale#
Pass originalPrice to render a strike-through using the sale
semantic token.
Spec#
variant="spec" swaps the consumer-stay layout for a product-spec
card. The default rating / host / favorite slots are ignored in this
variant; use flag, category, meta, specs, pricePrefix, and
cta instead.
Click & hover#
Pass onClick to make the whole card surface clickable — an invisible
stretched <button> sits below the inner actions (favorite, CTA,
links) so those keep their own click semantics. href still works for
plain navigation; if both are set, onClick wins.
hoverEffect picks the visual treatment. Defaults to lift when the
card is interactive (has onClick or href), otherwise none.
Editable sections#
Every text slot (title, eyebrow, category, meta, spec cells,
price, host, …) accepts arbitrary ReactNode. Drop in <InlineEdit>
to make any cell editable in place. Wrap editor cells in
<span className="relative z-10" onClick={(e) => e.stopPropagation()}>
when the card also has onClick, so dbl-click-to-edit doesn't open
the detail.
Flag pill (ListingCardFlag)#
Used in the spec variant for the top-left badge ("Flagship", "New", "Editor's pick").
label: ReactNode(required) — the pill text.icon?: GlyphName— optional leading glyph from the design-system manifest.tone?: 'accent' | 'purple' | 'pink' | 'ok' | 'warn'— color palette. Default'accent'.
Spec cell (ListingCardSpec)#
Each cell in the spec grid (specs[] on variant="spec").
label: ReactNode(required) — small uppercase label above the value (e.g. "0-60", "Power", "Drive").value: ReactNode(required) — the headline value ("2.9s", "495 hp", "RWD").
The grid auto-columns to length (2, 3, or 4-up); past 4 cells it
wraps to a 4-column grid.
Footer CTA (ListingCardCta)#
The bottom CTA button on variant="spec". When set, the whole-card
stretched link is suppressed so the CTA wins the click.
label: ReactNode(required) — button text.onClick?: () => void— click handler.href?: string— when set, renders as an<a>for native navigation.disabled?: boolean— disables without removing the button.
Styling customization#
Every section is independently styleable via the classNames slot
map. Each key targets one internal element; values are merged with the
component's own utilities via cn() — so consumers can override,
extend, or replace any styling without forking.
Slots: root, photos, flag, photoCounter, favorite, body,
header, title, eyebrow, category, meta, specs, specCell,
specLabel, specValue, footer, price, priceUnit, cta.
AI / SEO surface#
ListingCard emits schema.org JSON-LD by default — Accommodation
for the default variant, Product for the spec variant. Override via
schema?: 'Accommodation' | 'Product' | 'Place' | 'LodgingBusiness'
or any custom string. Pass priceCurrency? to include an offers
block (otherwise prices are skipped from JSON-LD), and url? to
populate the entity URL. Spec cells become additionalProperty
entries. Pass noStructuredData to suppress emission entirely.
Top-level props#
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | enum | default | Visual variant. Default `default`. |
| photos * | readonly string[] | — | Photo URLs (or anything `Carousel` can render). At least one. |
| renderPhoto | ((src: string, index: number) => ReactNode) | undefined | — | Override the photo renderer. Defaults to a decorative `<img src>`. Use this when consumers need theme-aware photos (e.g. inline SVG placeholders that follow `currentColor`) or non-image slides. |
| loop | boolean | "circular" | "sweep" | undefined | true | Wrap the photo carousel past the boundaries (next from the last photo goes to the first). Default `true` — marketplace photo browsing expects looping. Pass `false` to restore stop-at-end, or `'circular'` / `'sweep'` to pick the loop variant explicitly. |
| title * | ReactNode | — | Listing title — e.g. "Sun-soaked cabin in Marin". |
| eyebrow | ReactNode | — | Optional eyebrow text above the title (location, listing type). |
| price * | ReactNode | — | Headline price (e.g. `189`). |
| priceUnit | ReactNode | /day | Price unit suffix (e.g. `/night`). |
| originalPrice | ReactNode | — | Original price for sale strike-through. |
| pricePrefix | ReactNode | — | Prefix shown before the price — e.g. "from". `spec` variant only. |
| width | string | number | undefined | variant === 'spec' ? 320 : 280 | Card width override. |
| href | string | undefined | — | Link target for the whole card (default) or the title (spec). |
| onClick | ((e: MouseEvent<HTMLButtonElement, MouseEvent>) => void) | undefined | — | Whole-card click handler. Renders an invisible stretched `<button>` underneath the inner actions (favorite, CTA, links) so clicks on those take precedence. Use this for "click card → open detail" without leaving the page. |
| hoverEffect | enum | — | Visual treatment on hover. Default `lift` when the card is interactive (has `onClick` / `href`), otherwise `none`. |
| rating | number | undefined | — | Average rating (0–5). When undefined, the rating row is hidden. |
| reviewCount | number | undefined | — | Number of reviews — shown next to the rating. |
| host | ReactNode | — | Host / owner name. |
| distance | ReactNode | — | Distance label (e.g. `0.4 mi away`). |
| verified | boolean | undefined | — | When true, shows a `verified` badge on the photo. |
| onFavorite | ((next: boolean) => void) | undefined | — | Heart-icon favorite toggle handler. |
| favorited | boolean | undefined | — | Current favorite state. |
| flag | ListingCardFlag | undefined | — | Pill rendered top-left of the photo. |
| category | ReactNode | — | Small category tag right-aligned in the title row. |
| meta | ReactNode | — | Dim secondary line under the title (e.g. listing ID · year). |
| specs | readonly ListingCardSpec[] | undefined | — | Spec cells rendered as a grid below the title block. |
| cta | ListingCardCta | undefined | — | Bottom CTA button. When set, no whole-card stretched link is rendered. |
| hidePhotoCounter | boolean | undefined | — | Hide the photo counter overlay in `spec` variant. Default `false`. |
| schema | (string & {}) | "Product" | "Accommodation" | "Place" | "LodgingBusiness" | undefined | — | schema.org `@type` for the JSON-LD entity. Defaults to `'Accommodation'` for the default variant and `'Product'` for the spec variant. Pass `'Place'`, `'LodgingBusiness'`, or any string to override. |
| priceCurrency | string | undefined | — | ISO 4217 currency code (e.g. `'USD'`). REQUIRED to emit the `offers` block — without it the offer is skipped (the rest of the entity still emits if `title` resolves to a string). |
| priceAmount | number | undefined | — | Explicit numeric price for JSON-LD. When omitted, parsed from the visible `price` prop by stripping non-numeric characters. Pass this when `price` is JSX or not cleanly parseable. |
| url | string | undefined | — | Optional URL of the listing detail page — also emitted as the entity `url`. |
| titleText | string | undefined | — | String version of `title` for the JSON-LD `name`. Required if `title` is JSX. |
| descriptionText | string | undefined | — | String version of `eyebrow` for the JSON-LD `description`. |
| noStructuredData | boolean | undefined | — | Opt out of emitting the JSON-LD script. |
| classNames | Partial<{ root: string; photos: string; flag: string; photoCounter: string; favorite: string; body: string; header: string; title: string; eyebrow: string; category: string; meta: string; specs: string; ... 6 more ...; cta: string; }> | undefined | {} | Per-section className overrides. Each key targets a specific element in the rendered tree; values are merged with the component's own utilities via `cn()` so consumers can override, extend, or replace any styling without forking the component. The `className` prop still controls the outer Card element — `classNames.root` is an alias that's also merged onto it. |
Composition#
The card is a thin assembly over primitives — Card for the shell,
Carousel for photos, Rating for the star row, Badge for
verified, and heart icon for favorite. For unusual layouts, compose
the same primitives yourself.