Ship-It Designv0.0.21

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) | 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.
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 *ReactNode—Listing title — e.g. "Sun-soaked cabin in Marin".
eyebrowReactNode—Optional eyebrow above the title — listing type, location.
descriptionReactNode—Long-form description body.
ratingnumber | undefined—Average rating (0–5). When undefined, the rating row is hidden.
reviewCountnumber | undefined—Total review count, shown next to the rating.
price *ReactNode—Headline price (e.g. `$189`).
priceUnitReactNode/daySuffix after the price (e.g. `/night`).
originalPriceReactNode—Original price for a strike-through; renders only when set.
pricePrefixReactNode—Prefix shown before the price — e.g. "from". `spec` variant only.
hostListingDetailHost | undefined—Host card data — name + avatar + optional verified / meta line.
featuresreadonly ListingDetailFeature[] | undefined—Feature chips (e.g. seats, fuel, A/C).
primaryActionListingDetailAction | undefined—Primary CTA — typically "Book now". Default variant.
secondaryActionListingDetailAction | undefined—Secondary CTA — typically "Message host". Default variant.
flagListingCardFlag | undefined—Pill rendered top-left of the photo.
categoryReactNode—Small category tag right-aligned in the title row.
metaReactNode—Dim secondary line under the title (e.g. listing ID · year).
specsreadonly ListingCardSpec[] | undefined—Spec cells rendered as a grid in the info column.
ctaListingCardCta | undefined—Primary CTA rendered in the dark bottom bar. `spec` variant.
hidePhotoCounterboolean | 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.
priceCurrencystring | 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).
priceAmountnumber | undefined—Explicit numeric price for JSON-LD. When omitted, parsed from the visible `price` prop by stripping non-numeric characters.
urlstring | undefined—Listing detail URL, also emitted as the entity `url`.
titleTextstring | undefined—String version of `title` for the JSON-LD `name`. Required if `title` is JSX.
descriptionTextstring | undefined—String version of `description` for the JSON-LD `description`.
noStructuredDataboolean | 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.
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.