ListingDetail
The full listing popup. Photos on the left (Carousel, click to enter
fullscreen via Lightbox), info on the right — title, rating, host,
feature chips, description, primary/secondary CTAs. Stacks on narrow
viewports. Built on Radix Dialog so focus trap, Escape, and ARIA
descriptions come for free.
Pair with ListingCard: the card is the grid affordance;
ListingDetail is the modal a user sees after clicking it. Saves
consumers from hand-assembling Dialog + Carousel + Lightbox + a
marketplace info layout.
The two variants mirror ListingCard:
default— rating, host, features, primary / secondary CTAs.spec— flag pill, photo counter, spec grid, dark CTA bar with a single primary action.
ListingDetail also emits schema.org JSON-LD by default — the script
is rendered as a sibling of the Radix Dialog root so crawlers see it
regardless of open state.
Default#
The gallery Carousel and the fullscreen Lightbox share one
galleryIndex — closing the lightbox lands the gallery on the photo
you left on, and one loop prop drives both surfaces (default true,
matching marketplace expectations). See
Carousel → Loop for the swipe-wrap
mechanics.
Spec#
variant="spec" mirrors ListingCard's spec variant at modal scale:
a flag pill on the photo, photo counter, a larger stats grid in the
info column, and a dark CTA bar at the bottom with the price + a
single primary action.
Host (ListingDetailHost)#
The host card rendered in the info column (default variant).
name: ReactNode(required) — host display name.avatarUrl?: string— host avatar URL.verified?: boolean— when true, renders a smallverifiedbadge next to the name.meta?: ReactNode— free-text line under the name (e.g. "Host since 2022 · 312 trips").
Feature chip (ListingDetailFeature)#
Each chip in the feature row (default variant) — typically things like "4 seats", "Auto", "Bluetooth".
icon: GlyphName(required) — glyph from the design-system manifest.label: ReactNode(required) — chip text.
Action (ListingDetailAction)#
Primary or secondary CTA in the default variant. The same shape is
used for both primaryAction and secondaryAction.
label: ReactNode(required) — button text.onClick?: () => void— click handler.href?: string— when set, renders the button as an<a>for native navigation.disabled?: boolean— disables without removing focusability, so tooltips on the disabled state still work.
Spec variant slots#
variant="spec" shares its flag / category / meta / specs
shapes with ListingCard. See
ListingCard → Flag pill,
Spec cell, and
Footer CTA for
each shape's fields.
AI / SEO surface#
Same shape as ListingCard. Default @type is Accommodation for
the default variant, Product for the spec variant. Override via
schema?. Pass priceCurrency? to include an offers block, url?
for the entity URL, titleText? and descriptionText? for the JSON-LD
name/description fallbacks when those props are JSX.
noStructuredData opts out entirely.
Accessibility#
role="dialog"with focus trap, Escape closes, Tab cycles controls.- The hidden
<DialogTitle>defaults to the string form oftitle. Whentitleis a React node, passariaLabelexplicitly so screen readers still get a real name for the dialog. - The gallery is a single
<button>witharia-label="Open photo viewer"— clicking promotes the active photo into a fullscreenLightbox. - The
Ratingrow uses a visually-hidden<Rating readOnly />so the star count and total reviews are announced. - Close button is always reachable in the top-right corner.
Top-level props#
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | enum | default | Visual variant. Default `default`. |
| open | boolean | undefined | — | |
| defaultOpen | boolean | undefined | — | |
| onOpenChange | ((open: boolean) => void) | undefined | — | |
| photos * | readonly string[] | — | Photo URLs. At least one. |
| renderPhoto | ((src: string, index: number, mode: "gallery" | "lightbox") => ReactNode) | undefined | — | Override the photo renderer for the gallery and the fullscreen `Lightbox`. Defaults to decorative `<img src>` (object-cover in the gallery, object-contain in the lightbox). Use this for theme-aware placeholders or non-image slides. |
| loop | boolean | "circular" | "sweep" | undefined | true | Wrap the gallery carousel and the fullscreen lightbox past the boundaries (next from the last photo goes to the first). Default `true` — marketplace photo browsing expects looping. One prop drives both surfaces. Pass `'circular'` or `'sweep'` to pick the gallery's loop variant explicitly; both forward as truthy to the lightbox. |
| title * | ReactNode | — | Listing title — e.g. "Sun-soaked cabin in Marin". |
| eyebrow | ReactNode | — | Optional eyebrow above the title — listing type, location. |
| description | ReactNode | — | Long-form description body. |
| rating | number | undefined | — | Average rating (0–5). When undefined, the rating row is hidden. |
| reviewCount | number | undefined | — | Total review count, shown next to the rating. |
| price * | ReactNode | — | Headline price (e.g. `$189`). |
| priceUnit | ReactNode | /day | Suffix after the price (e.g. `/night`). |
| originalPrice | ReactNode | — | Original price for a strike-through; renders only when set. |
| pricePrefix | ReactNode | — | Prefix shown before the price — e.g. "from". `spec` variant only. |
| host | ListingDetailHost | undefined | — | Host card data — name + avatar + optional verified / meta line. |
| features | readonly ListingDetailFeature[] | undefined | — | Feature chips (e.g. seats, fuel, A/C). |
| primaryAction | ListingDetailAction | undefined | — | Primary CTA — typically "Book now". Default variant. |
| secondaryAction | ListingDetailAction | undefined | — | Secondary CTA — typically "Message host". Default variant. |
| 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 in the info column. |
| cta | ListingCardCta | undefined | — | Primary CTA rendered in the dark bottom bar. `spec` variant. |
| 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 custom 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. |
| url | string | undefined | — | Listing detail URL, 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 `description` for the JSON-LD `description`. |
| noStructuredData | boolean | undefined | — | Opt out of emitting the JSON-LD `<script>`. The script renders as a sibling of the Radix Dialog root (outside the Portal) so it appears in the SSR'd HTML regardless of `open` state — crawlers see it always. |
| classNames | Partial<{ overlay: string; content: string; grid: string; photos: string; info: string; header: string; title: string; category: string; meta: string; specs: string; specCell: string; specLabel: string; ... 10 more ...; close: 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()`. |
Composition#
ListingDetail is an opinionated assembly over Carousel,
Lightbox, Rating, Avatar, Badge, and Button. If you need an
unusual layout (e.g. a sticky booking panel, embedded map, host
reviews list), drop down to those primitives — the source file is a
fair reference.