Ship-It Designv0.0.20

GitHub

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.

Loading…

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.

Loading…

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 small verified badge 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 of title. When title is a React node, pass ariaLabel explicitly so screen readers still get a real name for the dialog.
  • The gallery is a single <button> with aria-label="Open photo viewer" — clicking promotes the active photo into a fullscreen Lightbox.
  • The Rating row 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#

Props for ListingDetail
PropTypeDefaultDescription
variantenumdefaultVisual variant. Default `default`.
openboolean | undefined
defaultOpenboolean | undefined
onOpenChange((open: boolean) => void) | undefined
photos *readonly string[]Photo URLs. At least one.
renderPhoto((src: string, index: number, mode: "gallery" | "lightbox") => ReactNode) | undefinedOverride 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.
loopboolean | "circular" | "sweep" | undefinedtrueWrap 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 *ReactNodeListing title — e.g. "Sun-soaked cabin in Marin".
eyebrowReactNodeOptional eyebrow above the title — listing type, location.
descriptionReactNodeLong-form description body.
ratingnumber | undefinedAverage rating (0–5). When undefined, the rating row is hidden.
reviewCountnumber | undefinedTotal review count, shown next to the rating.
price *ReactNodeHeadline price (e.g. `$189`).
priceUnitReactNode/daySuffix after the price (e.g. `/night`).
originalPriceReactNodeOriginal price for a strike-through; renders only when set.
pricePrefixReactNodePrefix shown before the price — e.g. "from". `spec` variant only.
hostListingDetailHost | undefinedHost card data — name + avatar + optional verified / meta line.
featuresreadonly ListingDetailFeature[] | undefinedFeature chips (e.g. seats, fuel, A/C).
primaryActionListingDetailAction | undefinedPrimary CTA — typically "Book now". Default variant.
secondaryActionListingDetailAction | undefinedSecondary CTA — typically "Message host". Default variant.
flagListingCardFlag | undefinedPill rendered top-left of the photo.
categoryReactNodeSmall category tag right-aligned in the title row.
metaReactNodeDim secondary line under the title (e.g. listing ID · year).
specsreadonly ListingCardSpec[] | undefinedSpec cells rendered as a grid in the info column.
ctaListingCardCta | undefinedPrimary CTA rendered in the dark bottom bar. `spec` variant.
hidePhotoCounterboolean | undefinedHide the photo counter overlay in `spec` variant. Default `false`.
schema(string & {}) | "Product" | "Accommodation" | "Place" | "LodgingBusiness" | undefinedschema.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.
priceCurrencystring | undefinedISO 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).
priceAmountnumber | undefinedExplicit numeric price for JSON-LD. When omitted, parsed from the visible `price` prop by stripping non-numeric characters.
urlstring | undefinedListing detail URL, also emitted as the entity `url`.
titleTextstring | undefinedString version of `title` for the JSON-LD `name`. Required if `title` is JSX.
descriptionTextstring | undefinedString version of `description` for the JSON-LD `description`.
noStructuredDataboolean | undefinedOpt 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.
classNamesPartial<{ 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.