Property Search

Full IDX property search: location autocomplete, map search (drag / draw), sort, filters, listing cards, pagination, like/save/share gated by the visitor session, and the listing detail dialog.

Fully headlessClient component@pandaidx/widgets 1.14.1

Anatomy

Every piece of Property Search as it nests in code. A root holds the data and state; its parts and hooks only work inside it. SearchDisclaimers must render in every layout (MLS and consent rules).

<PandaProvider>Mounted once in app/layout.tsx
<PropertySearch />complete widget

Full IDX property search: location autocomplete, map search (drag / draw), sort, filters, listing cards, pagination, like/save/share gated…

<PropertySearchRoot>Holds the data and state its parts and hooks read
<SearchHeader />

The websites search header (logo from header.logo, autocomplete, links, account), position: fixed with its own spacer:…

<SearchFilters />

Filter pills (Status, Type, Price, Beds/Baths, All Filters, Save Search).

<SearchResultsHeader />

Result count + sort menu.

<SearchResults />

Cards of the current page in the root columns, pagination, empty state and the MLS + Panda disclaimers (required by the…

<ListingRow />

Horizontal result row: photo left, price, title, address, beds · baths · size, Listing by <office>, and the ⋯ actions…

<ListingMlsBadge />

The MLS number pill of the Panda cards for your own card (id = the listing's ListingId): a click (or Enter / Space)…

<ListingActionsMenu />

One ⋯ dropdown with every action of a listing (onShare opens your own share dialog): items in order from like,…

<SearchPanel />

Frame of a results panel that slides between its list and a listing's detail: relative, clipped, h-full of the box you…

<SearchPanelList />

The list side of SearchPanel: your header, results and pagination as children.

<SearchPanelDetail />

The detail side of SearchPanel (open.listing.mode: "panel"): holds a PropertyDetailRoot for the opened listing, so…

<SearchPanelBack />

Back to the list: place it at the top of SearchPanelDetail.

<SearchMap />

Results map (price pins, draw, search on move).

<SearchDisclaimers />required

MLS + Panda disclaimers (required on every search page): already inside SearchResults; render it when you build the…

<SearchMobile />

The complete phone search (pill header, filters, list / map toggle), hidden from lg: render it next to the desktop…

<ListingCard />

One listing card (listing from usePropertySearch().listings), with the root's card design and open contract; hover…

<FilterLocation />

Location / AI search box alone (cities, areas, zips, buildings, addresses, MLS#, natural language).

<FilterStatus />

Status filter: variant popover (pill, default) | inline (controls always open, for a sidebar).

<FilterType />

Property type + subtypes filter (variant popover | inline).

<FilterPrice />

Min / max price filter with the histogram (variant popover | inline).

<FilterBedsBaths />

Beds and baths filter (variant popover | inline).

<FilterSaleRent />

For Sale / For Rent switch.

<FilterSort />

Sort menu (newest, price high / low, beds, baths).

<FilterMore />

All filters button (drawer: features, HOA, year, lot, waterfront…).

<FilterSaveSearch />

Save Search button (sign-in first).

</PropertySearchRoot>
</PandaProvider>

Installation

  1. 1
    npm install <tarball or @pandaidx/widgets>
    Terminal
    npm install https://github.com/pandaidx/panda-widgets/releases/download/v1.14.1/pandaidx-widgets-1.14.1.tgz
  2. 2
    import "@pandaidx/widgets/styles.css" in app/layout.tsx
  3. 3
    Wrap the site in <PandaProvider publicKey apiUrl preset theme> (app/layout.tsx)
  4. 4
    In each page (Server Component) call the widget's server loader, then render the widget with its data
  5. 5
    Allow the images: next.config images.remotePatterns [{ protocol: "https", hostname: "**" }] (listing and blog photos come from several CDNs)

Usage

PropertySearch
// app/properties/page.tsx
import { PropertySearch } from "@pandaidx/widgets/client"
import { fetchPropertySearchBootstrap } from "@pandaidx/widgets/server"
export default async function PropertiesPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const initialData = await fetchPropertySearchBootstrap({
publicKey: process.env.NEXT_PUBLIC_PANDA_PUBLIC_KEY!,
apiUrl: process.env.NEXT_PUBLIC_PANDA_API_URL,
searchParams: await searchParams,
pageSize: 12,
})
return <PropertySearch initialData={initialData} searchPath="/properties" layout="map-right" mapWidth="40%" columns={2} cardVariant="classic" pageSize={12} />
}
// Panda websites search (app/search/page.tsx, no site header on that page):
// <PropertySearch initialData={initialData} searchPath="/search" layout="panda" header={{ logo: { src: site.brand.logoSecondHeader ?? site.brand.logo, alt: site.profile.name } }} />

Examples

Click a row: the panel slides to a compact detail (smaller gallery, details below) with Back

PropertySearchRoot
{/* <PandaOpen value={{ listing: { mode: "panel" } }}> in app/site-shell.tsx (or open={{ listing: { mode: "panel" } }} on the root). app/globals.css: .search-panel { position: absolute; top: 1rem; bottom: 1rem; left: 1rem; width: 40rem; overflow: hidden } */}
<PropertySearchRoot initialData={initialData} searchPath="/search" scrollContainer=".search-panel-list">
<SearchMap avoid=".search-panel" className={hostClass("absolute inset-0 h-full w-full")} />
<aside className={hostClass("search-panel rounded-card bg-background shadow-raised")}>
<SearchPanel>
<SearchPanelList className="search-panel-list">
<SearchResultsHeader sticky compact countLabel="{n} homes" />
<SearchResults layout="list" inset="none" />
</SearchPanelList>
<SearchPanelDetail design={{ gallery: { variant: "carousel" }, map: { nearby: true } }}>
<SearchPanelBack />
<PropertyDetailGallery />
<div className="flex flex-col gap-4 p-5">
<PropertyDetailHeading compact />
<PropertyDetailActions />
<PropertyDetailSections />
<PropertyCompliance />
</div>
</SearchPanelDetail>
</SearchPanel>
</aside>
</PropertySearchRoot>

Draw on map: polygon + radius, dashed brand-colored area, Cancel / Apply at the bottom center, no Schools control

PropertySearchRoot
<PropertySearchRoot initialData={initialData} searchPath="/search">
<SearchMap
className="h-160"
schools={false}
look={{ preset: "minimal" }}
draw={{ modes: ["polygon", "radius"], controls: "bottom-center", banner: "bottom", stroke: "#0b6e78", fillOpacity: 0.18, weight: 3, dash: true, labels: { drawOnMap: "Draw your area" } }}
/>
</PropertySearchRoot>
// Site-wide instead: DESIGN.md `map: { preset: minimal, shape: { stroke: "{colors.primary}", dash: true }, banner: { bg: "{colors.primary}", radius: 12px } }`.

Floating results panel with a sticky header, rows flush with it and your own skeleton

PropertySearchRoot
{/* app/globals.css: .search-panel { overflow-y: auto; padding: 0 1.25rem 1.25rem; } */}
<PropertySearchRoot initialData={initialData} searchPath="/search" scrollContainer=".search-panel">
<aside className={hostClass("search-panel rounded-card bg-background shadow-raised")}>
<SearchResultsHeader sticky compact countLabel="{n} homes" sort={<FilterSort variant="text" prefix={false} />} />
<SearchResults layout="list" inset="none" disclaimers="inline" skeleton={<MyRowSkeleton />} skeletonCount={6} />
</aside>
</PropertySearchRoot>

Results as a list (rows) with one actions menu per listing

PropertySearchRoot
<PropertySearchRoot initialData={initialData} searchPath="/search">
<SearchResultsHeader compact countLabel="{n} homes" sort={<FilterSort variant="text" prefix={false} />} />
<SearchResults layout="list" menuItems={["like", "collection", "share", "mortgage", "video", "sheet"]} disclaimers="inline" />
</PropertySearchRoot>

Map-first (Google Maps style): full-bleed map, results in a floating scrollable panel, Filters inside the search pill

PropertySearchRoot
{/* app/globals.css: .search-stage { position: relative; height: calc(100dvh - <header height>); overflow: hidden }
.search-panel { position: absolute; top: 1rem; bottom: 1rem; left: 1rem; z-index: 10; width: 40rem; overflow-y: auto } */}
<PropertySearchRoot initialData={initialData} searchPath="/search" columns={2}>
{/* Search box with the all-filters trigger INSIDE its pill (slot, never an absolute overlay). */}
<FilterLocation className={hostClass("hidden lg:block")}>
<FilterMore><span className={hostClass("inline-flex h-8 items-center rounded-pill bg-inverse px-3.5 text-inverse-foreground")}>Filters</span></FilterMore>
</FilterLocation>
<div className={hostClass("search-stage hidden lg:block")}>
{/* avoid: the camera frames the results in the area the panel leaves free. */}
<SearchMap avoid=".search-panel" className={hostClass("absolute inset-0 h-full w-full")} />
<aside className={hostClass("search-panel rounded-card bg-background p-5 shadow-raised")}>
<SearchResultsHeader />
<SearchResults />
</aside>
<div className={hostClass("absolute right-4 bottom-6 z-10")}><FilterSaveSearch /></div>
</div>
<SearchMobile />
</PropertySearchRoot>

Filters in a left sidebar, results grid, map on the right

PropertySearchRoot
<PropertySearchRoot initialData={initialData} searchPath="/search" columns={2}>
{/* self-start lets the sticky columns stick; the results take 2 of 4 columns so the cards stay wide. */}
<div className="hidden lg:grid lg:grid-cols-4 lg:gap-6 lg:px-6">
<SearchFilters className="sticky top-24 flex-col items-start self-start" />
<div className="lg:col-span-2"><SearchResultsHeader /><SearchResults /></div>
<SearchMap className="sticky top-24 h-160 self-start" />
</div>
<SearchMobile />
</PropertySearchRoot>
// Sizes: spacing tokens (h-160) or the site's DESIGN.md values; the design lint rejects arbitrary values like h-[calc(...)].

Filters always open in a sidebar (inline), location box on top

PropertySearchRoot
<PropertySearchRoot initialData={initialData} searchPath="/search" columns={2}>
<div className="hidden lg:grid lg:grid-cols-[320px_1fr] lg:gap-8 lg:px-6">
<aside className="sticky top-24 flex flex-col gap-8 self-start">
<FilterLocation />
<FilterSaleRent />
<FilterPrice variant="inline" />
<FilterBedsBaths variant="inline" />
<FilterType variant="inline" />
<FilterMore />
</aside>
<main><div className="flex justify-between"><SearchResultsHeader /><FilterSaveSearch /></div><SearchResults /></main>
</div>
<SearchMobile />
</PropertySearchRoot>

A filter of your own with the headless hook (pool chip)

PropertySearchRoot
"use client"
import { useSearchFilter } from "@pandaidx/widgets/client"
export const PoolChip = () => {
const pool = useSearchFilter("pool")
const on = pool.value === true || pool.value === "true"
return <button type="button" aria-pressed={on} onClick={() => (on ? pool.clear() : pool.set(true))}>Pool</button>
}

Fully site-drawn search: your filter bar (Save Search and the location box stay widget parts), your result cards from `cards`, SearchMap and the disclaimers

PropertySearchRoot
"use client"
import Image from "next/image"
import { FilterLocation, FilterSaveSearch, SearchDisclaimers, SearchMap, useListingActions, usePropertySearch, useSearchFilter, type SearchResultListing } from "@pandaidx/widgets/client"
const SaleRent = () => {
const group = useSearchFilter("group")
return (
<div role="group" aria-label="Sale or rent" className="flex gap-1">
{(["sale", "rent"] as const).map((value) => (
<button key={value} type="button" aria-pressed={(group.value ?? "sale") === value} onClick={() => group.set(value)} className="h-10 rounded-full border px-4">
{value === "sale" ? "For Sale" : "For Rent"}
</button>
))}
</div>
)
}
const SortSelect = () => {
const { sortOptions } = usePropertySearch()
const sort = useSearchFilter("sort")
return (
<select aria-label="Sort" value={String(sort.value ?? "-listdate")} onChange={(event) => sort.set(event.target.value)} className="h-10 rounded-full border px-4">
{sortOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
)
}
const ResultCard = ({ card }: { card: SearchResultListing }) => {
const { highlight, highlighted } = usePropertySearch()
const actions = useListingActions(card)
return (
<article onPointerEnter={() => highlight(card.markerKey)} onPointerLeave={() => highlight(false)} data-active={highlighted === card.markerKey || undefined}>
<button type="button" onClick={actions.openDetail} className="block w-full text-left">
<div className="relative aspect-[4/3] overflow-hidden rounded-card bg-muted">
{card.photos[0] ? <Image src={card.photos[0]} alt={card.address} fill sizes="(min-width: 1024px) 25vw, 50vw" className="object-cover" /> : null}
</div>
<p className="mt-3 text-xl">{card.price}</p>
<p>{card.address}</p>
<p className="text-sm opacity-70">{[card.beds != null && `${card.beds} Beds`, card.baths != null && `${card.baths} Baths`].filter(Boolean).join(" · ")}</p>
{card.courtesy ? <p className="mt-1 text-xs opacity-60">{card.courtesy}</p> : null}
</button>
</article>
)
}
export const SearchView = () => {
const { cards, total, loading, page, pageCount, goToPage } = usePropertySearch()
return (
<>
<div className="flex flex-wrap items-center gap-2 px-6 py-3">
<FilterLocation className="w-80" />
<SaleRent />
<SortSelect />
<FilterSaveSearch />
</div>
<div className="grid lg:grid-cols-[1fr_480px]">
<section className="px-6" aria-busy={loading}>
<p data-panda-count="">{total.toLocaleString()} homes</p>
<div className="grid gap-6 sm:grid-cols-2">{cards.map((card) => <ResultCard key={card.markerKey} card={card} />)}</div>
<nav className="my-8 flex justify-between">
<button type="button" disabled={page <= 0} onClick={() => goToPage(page - 1)}>Previous</button>
<button type="button" disabled={page + 1 >= pageCount} onClick={() => goToPage(page + 1)}>Next</button>
</nav>
<SearchDisclaimers />
</section>
<SearchMap className="sticky top-24 h-[calc(100dvh-6rem)]" />
</div>
</>
)
}
// app/search/page.tsx: <PropertySearchRoot initialData={initialData} searchPath="/search"><SearchView /></PropertySearchRoot>

Own results list with the hook (keep ListingCard and the disclaimers)

PropertySearchRoot
const Results = () => {
const { listings, loading, page, pageCount, goToPage } = usePropertySearch()
if (loading) return <p>Loading…</p>
return (
<>
<div className="grid gap-6 md:grid-cols-3">{listings.map((l, i) => <ListingCard key={String(l._id ?? l.ListingId)} listing={l} index={i} />)}</div>
<button disabled={page + 1 >= pageCount} onClick={() => goToPage(page + 1)}>Next</button>
<SearchDisclaimers />
</>
)
}

API reference

PropertySearch

For another arrangement of its pieces (filters in a sidebar, map on top, your own results grid) use PropertySearchRoot + its parts. Full IDX property search: location autocomplete, map search (drag / draw), sort, filters, listing cards, pagination, like/save/share gated by the visitor session, and the listing detail dialog. Fields follow ONE field definition, DESIGN.md components.field (height, heightSm, heightLg, radius, paddingX, paddingXLg, background, border, focusBorder, foreground, placeholder, fontSize, fontSizeSm, fontSizeLg, fontLg, iconSize, iconInset, textareaMinHeight, labelSize, labelWeight, labelTracking, checkboxRadius, variant, variantLg): every input, select, phone, textarea, date/time field, checkbox, radio, form field and the HomeValuation address bar of every widget reads it (filters use heightSm, size="lg" forms heightLg and variantLg, e.g. underline for conversational forms); declare the keys there, never restyle widget fields in app/globals.css. Control corners (inputs, selects, segmented tabs, checkboxes: the All Filters dialog, sign-in, forms) follow ONE token, the site's field radius: DESIGN.md rounded.input → --panda-radius-control (theme.radius.control for a deliberate one-off). There is no prop for it and nothing to style: change that single value (0px square, 6px soft); fields with input.variant: underline are always square, so use outline or filled to see it. Checkboxes take 0.625× of it (8px → 5px, 0px stays square); --panda-checkbox-radius on the widget root overrides only them. Buttons follow rounded.button, cards rounded.card, dialogs rounded.dialog. If the corners do not change, something outside the widget overrides the token: delete any [data-panda-widget] .rounded-* or button[role="checkbox"] rule in app/globals.css (the widgets already map their own radius scale onto the token, so such a rule only pins the corners), or, on a page under a PandaDefaultLook wrapper (it ignores DESIGN.md), set both control of PANDA_DEFAULT_THEME_RADIUS and --panda-host-radius-control of PANDA_DEFAULT_RADII to var(--ds-radius-input) in components/site/panda-default-look.tsx.

Import from @pandaidx/widgets/client · renders inside PandaProvider

PropTypeDefaultDescription
initialData*object—First page and public application from the server loader (SEO, no loading flash).
searchPathstring"/properties"Host page path the shareable search URL is written to; listing links are <searchPath>/<ListingId>. The search opens with the filters of the URL (/search?q=AVENTURA&group=rent&minbeds=2, built with buildSearchHref from @pandaidx/widgets/search) and every filter change is written back to it (history.replaceState, like websites).
querystring—Fixed filters of a page with a preset search (area, library or saved-search page): a serialized query (q=AVENTURA&group=rent, a saved search query) or filters { q, group, status, type, minprice, minbeds… }. Seeds the search; pass the same value to fetchPropertySearchBootstrap({ query }). URL params win key by key.
layout"map-left" | "map-right" | "list-only" | "map-only-toggle" | "panda""map-left"Map beside the results (left/right), no map, or list with a Map/List toggle (template 1 search). panda mounts the Panda websites search instead: fixed header with the pill autocomplete, sticky filter pills (Status, Type, Price, Beds/Baths, All Filters, Save Search), "N places" + sort, 60/40 results and rounded sticky map, websites listing cards, and the pill mobile header with the Map/Grid button. Render it on a page without the site header (it brings its own, like websites /search). Only searchPath, header, placeholder, card, pageSize, sort, columns, mapWidth and theme props apply to it: columns={2} = two cards per row on desktop (default 2, 3 from 1536px wide), mapWidth="50%" = half map / half results (default 60/40). With every layout, card (panda cards), detail (detail design) and open (how a listing opens) apply.
headerobject—panda layout header. Default: the "Logo" placeholder, Collections / Saved Searches links and the account icon, no hamburger.
placeholderstring—Placeholder of the location search (template 1 default "Enter City, Address or Zip"; panda default "Enter city, condo, zip code, MLS#, or address").
searchBarVariant"square" | "pill""square"Template 1 location bar: square field with a SEARCH button, or the websites rounded pill with a shadow.
cardobject—Design of the websites listing cards (layout: "panda" or cardVariant: "panda"); every field optional, empty = the websites card.
mapWidthstring | number—Desktop width of the map beside the results: "40%", "60%", 55 (= 55%), "640px". Unset: at least half the viewport, results capped at 720px. layout="panda" too: mapWidth="50%" splits the page half map / half results (unset: the websites 60/40, 52% map from 2190px); mobile unchanged.
columnsobject1Result columns: a number 1-4 (desktop; phones 1, tablets up to 2) or { base, sm, md, lg, xl } per breakpoint. layout="panda" too (desktop only, mobile unchanged): columns={2} = two cards per row; unset = 2, or 3 from 1536px wide, so set it explicitly to pin the count.
cardVariant"horizontal" | "classic" | "overlay" | "minimal" | "panda""horizontal"Listing card: photo left + details right (horizontal), photo on top (classic), details over the photo (overlay), frameless (minimal), or the Panda websites card (panda: 7/5 hover gallery, MLS#/age/status badges, share/like/collection, websites detail dialog).
cardImageRatiostring"4/3"Photo aspect ratio of vertical cards ("4/3", "16/9", "1/1").
showFiltersbooleantrueShows the filters control.
filtersPlacement"bar" | "sidebar" | "dialog""bar"Filters button opening a side sheet (bar), an inline panel beside the results on desktop (sidebar), or a centered dialog (dialog).
stickySearchBarbooleanfalsePins the search bar and toolbar while the results scroll. With a fixed site header, set --panda-search-top to its height (e.g. :root { --panda-search-top: 73px } in app/globals.css): the bar pins under it and the side map stays fixed at full height below it.
showSaveSearchbooleantrueShows the Save Search button (signed-out visitors get the sign-up dialog).
pageSizenumber12Listings per page; pass the same value to the loader (pageSize).
sort"-listdate" | "listprice" | "-listprice" | "-modificationtimestamp"—Default sort when the URL has none; pass the same value to the loader (sort).
detailobject—Layout of the listing detail dialog opened from a card (same options as PropertyDetail layout props). { variant: "panda", design, parts } opens the Panda websites detail shaped by design (same shape as PropertyDetail.design, merged over PandaProvider propertyDetail); parts replaces one piece (gallery, agent) like PropertyDetail parts.
openobject—How a listing opens from a card or a map pin (merged over PandaProvider open.listing), with every layout. Default: centered dialog, and the open listing already lives in ?listing=<id> (shareable, reopens on load) — set open only to change that. Example: { mode: "sheet", size: "md" } (right panel) or { mode: "page", href: "/mls/{id}" }.
classNamesobject—Host classes appended to slots: root, searchBar, card, button, dialog.
preset"default" | "template1"—Preset for this widget only (defaults to the provider preset).
variant"default" | "template1"—Deprecated alias of preset.
themeobject—Token overrides for this widget only, merged over the provider theme (same shape as PandaProvider.theme).

PropertySearchRoot

Root of a property search you lay out yourself: the same data, query, URL sync, card design (card), detail dialog (detail), opening (open) and ?listing= deep link as PropertySearch, with no layout. Place its parts (same import) in any order or grid inside it: SearchHeader (the websites fixed header: logo, autocomplete, links, account; desktop), SearchFilters (filter pills: Status, Type, Price, Beds/Baths, All Filters, Save Search; desktop), SearchResultsHeader ("N places" + sort), SearchResults (cards in columns, pagination, empty state and the MLS disclaimers: required), SearchMap (320px tall unless className sets a height), SearchDisclaimers (required when you build the results yourself), SearchMobile (the whole phone search, hidden from lg) and ListingCard (one card of usePropertySearch().listings). usePropertySearch() returns { listings, cards (typed SearchResultListing[] for your own cards, markerKey for pin sync), total, limit, loading, query, search(filters), clear, page, pageCount, goToPage, mapView, toggleMapView, highlighted, highlight, sortOptions, propertyTypes } (search goes back to page 1 and scrolls to the top) to rebuild the results, the pagination or a filter. Prefer PropertySearch layout="panda" + its props when they cover the page; compose for another arrangement (filters in a sidebar, map on top, results in your own grid).

Import from @pandaidx/widgets/client · renders inside PandaProvider

PropTypeDefaultDescription
initialData*object—First page and public application from the server loader (SEO, no loading flash).
searchPathstring"/properties"Host page path the shareable search URL is written to; listing links are <searchPath>/<ListingId>. The search opens with the filters of the URL (/search?q=AVENTURA&group=rent&minbeds=2, built with buildSearchHref from @pandaidx/widgets/search) and every filter change is written back to it (history.replaceState, like websites).
querystring—Fixed filters of a page with a preset search (area, library or saved-search page): a serialized query (q=AVENTURA&group=rent, a saved search query) or filters { q, group, status, type, minprice, minbeds… }. Seeds the search; pass the same value to fetchPropertySearchBootstrap({ query }). URL params win key by key.
pageSizenumber12Listings per page; pass the same value to the loader (pageSize).
sort"-listdate" | "listprice" | "-listprice" | "-modificationtimestamp"—Default sort when the URL has none; pass the same value to the loader (sort).
cardobject—Design of the websites listing cards (layout: "panda" or cardVariant: "panda"); every field optional, empty = the websites card.
detailobject—Layout of the listing detail dialog opened from a card (same options as PropertyDetail layout props). { variant: "panda", design, parts } opens the Panda websites detail shaped by design (same shape as PropertyDetail.design, merged over PandaProvider propertyDetail); parts replaces one piece (gallery, agent) like PropertyDetail parts.
openobject—How a listing opens from a card or a map pin (merged over PandaProvider open.listing), with every layout. Default: centered dialog, and the open listing already lives in ?listing=<id> (shareable, reopens on load) — set open only to change that. Example: { mode: "sheet", size: "md" } (right panel) or { mode: "page", href: "/mls/{id}" }.
headerobject—panda layout header. Default: the "Logo" placeholder, Collections / Saved Searches links and the account icon, no hamburger.
placeholderstring—Placeholder of the location search (template 1 default "Enter City, Address or Zip"; panda default "Enter city, condo, zip code, MLS#, or address").
classNamesobject—Host classes appended to slots: root, searchBar, card, button, dialog.
columnsobject1Result columns: a number 1-4 (desktop; phones 1, tablets up to 2) or { base, sm, md, lg, xl } per breakpoint. layout="panda" too (desktop only, mobile unchanged): columns={2} = two cards per row; unset = 2, or 3 from 1536px wide, so set it explicitly to pin the count.
collectionPickerobject—"Add to collection" of the listing actions (layout="list" rows, ListingActionsMenu) as a dialog with the visitor's collections (layout: list | grid; labels: title, create, empty…) and one bottom button (doneLabel); mode: "sheet" (+ side: right | bottom) slides it in as a sheet instead of a dialog. Default: the Panda collections dialog. Serializable: set it from a server page. Site-wide for every card, row and detail: <PandaOpen value={{ collectionPicker: { mode: "sheet" } }}> in app/site-shell.tsx.
scrollContainerstring—CSS selector of your scrolling results panel (".search-panel", an overflow-auto element): a new search or page scrolls it to the top instead of the window (which does nothing inside a floating panel). Pair it with SearchResultsHeader sticky.
children*node—Your layout with the search parts (and your own sections).
preset"default" | "template1"—Preset for this widget only (defaults to the provider preset).
variant"default" | "template1"—Deprecated alias of preset.
themeobject—Token overrides for this widget only, merged over the provider theme (same shape as PandaProvider.theme).

Parts

<SearchHeader />

The websites search header (logo from header.logo, autocomplete, links, account), position: fixed with its own spacer: only on a page without the site header. Desktop only.

<SearchFilters />

Filter pills (Status, Type, Price, Beds/Baths, All Filters, Save Search). Desktop only; className places it (sticky bar, sidebar).

<SearchResultsHeader />

Result count + sort menu. compact drops its outer height and padding (inside your own panel); countLabel="{n} homes" writes the count (server-friendly; count={(n) => …} in a client component); sort={<FilterSort variant="text" prefix={false} />} swaps the sort control (false hides it). sticky keeps it at the top of your overflow-auto panel while the results scroll under it (part search.results-header: bg, padding-top, padding-x, border); give the panel the same selector in PropertySearchRoot scrollContainer=".search-panel" so a new search or page scrolls the panel, not the window.

<SearchResults />

Cards of the current page in the root columns, pagination, empty state and the MLS + Panda disclaimers (required by the IDX rules). disclaimers="inline": MLS logos on the left of their text. layout="list": one horizontal row per listing (ListingRow) with a ⋯ menu of every action (menuItems picks them); renderCard={(listing) => …} (client component only) draws your own card: show its ListOfficeName courtesy line and use useListingActions / <ListingActionsMenu>. Inside a floating panel that owns its padding: inset="none" (rows still line up with the header). Loading: skeleton={<MySkeleton />} (one placeholder, repeated skeletonCount times, max 12; a server element works) replaces the built-in card / row skeleton, which --panda-listing-skeleton-bg recolors.

<ListingRow />

Horizontal result row: photo left, price, title, address, beds · baths · size, Listing by <office>, and the ⋯ actions menu (actions="none" hides it; menuItems). Opens the listing detail.

<ListingMlsBadge />

The MLS number pill of the Panda cards for your own card (id = the listing's ListingId): a click (or Enter / Space) copies the number and the pill swaps to a check + copiedLabel (default "Copied") for 1.5s, then back, at a stable width; no toast (feedback: check | toast | none; onCopy after the copy). Reads the card badge tokens (--panda-listing-card-badge-*); place it over your photo (absolute top-3 left-3).

<ListingActionsMenu />

One ⋯ dropdown with every action of a listing (onShare opens your own share dialog): items in order from like, collection, share, mortgage, video, sheet (unavailable ones hide: video needs a tour); labels renames them. Signed-out visitors get the sign-up dialog. For your own cards and rows: listing takes a search row OR a getSearchResults item as is (plain data, so it works from a server page inside the root).

<SearchPanel />

Frame of a results panel that slides between its list and a listing's detail: relative, clipped, h-full of the box you give it (your floating aside). Holds SearchPanelList and SearchPanelDetail. Variables (part search.panel): duration (300ms), easing, offset (how far the list drifts left, 25%), bg (detail background).

<SearchPanelList />

The list side of SearchPanel: your header, results and pagination as children. It scrolls on its own (give it the class scrollContainer points at) and stays mounted behind the detail, so Back returns to the same scroll, filters, page and map selection.

<SearchPanelDetail />

The detail side of SearchPanel (open.listing.mode: "panel"): holds a PropertyDetailRoot for the opened listing, so every detail part and hook works inside it. Children = your layout (smaller PropertyDetailGallery, PropertyDetailHeading, PropertySchools…, a SearchPanelBack at the top) and must render PropertyCompliance or PropertyDetailDisclaimer (MLS rules). Omitted children: Back + gallery + heading + price + actions + sections + disclaimer. design sets the gallery / actions / map / sections of this detail.

<SearchPanelBack />

Back to the list: place it at the top of SearchPanelDetail. label (default Back to results), or your own children.

<SearchMap />

Results map (price pins, draw, search on move). 320px tall by default; set its height with className (a spacing token like h-160, self-start in a sticky column). look={{ preset: "minimal", colors, layers }} paints this map only (DESIGN.md map: sets it site-wide). Draw on map: draw={{ modes: ["polygon", "radius"], banner: "bottom" | "none", controls: "bottom-center", stroke, fill, fillOpacity, weight, dash, labels }} (modes: freehand = press + drag, polygon = click per vertex + Apply, radius = press + drag a circle around a point; Cancel / Apply sit top-right or bottom-center; the hint strip is the map.draw-banner part, the pills map.control, the area map.shape: variables or DESIGN.md map: { shape, banner, controls }). schools={false} hides the Schools control. Never import the map package: these props cover it. Full-bleed map with your own panel floating over it: avoid=".search-panel" (selector of the floating elements) pads the camera so results, centering and the selected pin stay in the uncovered area, like Google Maps.

<SearchDisclaimers />required

MLS + Panda disclaimers (required on every search page): already inside SearchResults; render it when you build the results yourself. layout="inline" puts each MLS logo on the left of its text (default stacked); spacing via the search.disclaimers part variables.

<SearchMobile />

The complete phone search (pill header, filters, list / map toggle), hidden from lg: render it next to the desktop parts.

<ListingCard />

One listing card (listing from usePropertySearch().listings), with the root's card design and open contract; hover highlights its pin. Pass index (its place in your grid): the first row (0-2) loads its photo eagerly, the rest lazily.

<FilterLocation />

Location / AI search box alone (cities, areas, zips, buildings, addresses, MLS#, natural language). children render inside the pill at its end, vertically centered in its row (e.g. <FilterLocation><FilterMore><span>Filters</span></FilterMore></FilterLocation>); never overlay your own elements on the pill.

<FilterStatus />

Status filter: variant popover (pill, default) | inline (controls always open, for a sidebar).

<FilterType />

Property type + subtypes filter (variant popover | inline).

<FilterPrice />

Min / max price filter with the histogram (variant popover | inline).

<FilterBedsBaths />

Beds and baths filter (variant popover | inline).

<FilterSaleRent />

For Sale / For Rent switch.

<FilterSort />

Sort menu (newest, price high / low, beds, baths). variant="text": plain text + chevron, no box; prefix (default Sort:, false hides it); options={[{ value, label }]} relabels or narrows (listprice, -listprice, -listdate, beds, baths).

<FilterMore />

All filters button (drawer: features, HOA, year, lot, waterfront…). children replaces the Panda pill with your own trigger content (<FilterMore className="…"><SlidersIcon /> Filters</FilterMore>), e.g. inside your own search bar.

<FilterSaveSearch />

Save Search button (sign-in first).

Hooks

useListingActions()inside PropertySearchRoot

{ signedIn, liked, likePending, toggleLike(), openCollections(), share(), openMortgage(), videoUrl, openVideo(), mlsSheetUrl, detailHref, openDetail(), available(action) } for one listing: a search row (_id, ListingId, OriginatingSystemKey, ListPrice, image, VirtualTourURLUnbranded) or a getSearchResults item as is (_id, id, feed, photos, listPrice, videoUrl). Dialogs open from the root's ListingActionsHost. share(onOpen) calls your onOpen instead of the Panda dialog and shareData gives its { url, title, image } (build the dialog with useShare). Client component only.

useListingPanel()inside PropertySearchRoot

{ listing, isOpen, loading, open(listing), back() } — the in-panel detail: open slides the panel to a listing (a search row or useListingActions listing), back returns to the list. Import from @pandaidx/widgets/client; inside SearchPanelDetail use usePropertyOverview() / the detail parts for the listing's data.

usePropertySearch()inside PropertySearchRoot

{ listings (raw rows, for ListingCard), cards: SearchResultListing[] ({ _id, id, feed, markerKey, path, title, address, price (formatted, lease qualifier), listPrice, beds, baths, livingArea, status, forSale, photos, coordinates, courtesy (MLS rule: render it on every card), videoUrl }: the current page typed for your own cards), total, limit (page size), loading, query, search(filters), clear(), page (0-based), pageCount, goToPage(page), mapView, toggleMapView(), highlighted, highlight(key) (pin sync: highlight(card.markerKey) on pointer enter, highlight(false) on leave, highlighted === card.markerKey marks the card), sortOptions ({ value, label }[] = SEARCH_SORT_OPTIONS: -listdate Newest Listings (default), listprice Lowest Price, -listprice Highest Price, beds Number of Beds, baths Number of Baths), propertyTypes ({ group: "sale" | "rent", type, label, description, subtypes: { label, value }[] }[] in the visitor's language) } — import from @pandaidx/widgets/client. Open a card's listing in the root's detail (dialog, sheet, panel or page per open) with useListingActions(card).openDetail() (it also gives like / share / collection); the card's path is the Panda detail route.

useSearchFilter()inside PropertySearchRoot

useSearchFilter(key) → { value, set(value), clear() } for one query key (minprice, maxprice, minbeds, maxbeds, minbaths, status, group sale | rent (switches type + subtypes), subtypes, sort, pool, waterfront…): chips, sliders or toggles of your own; each set searches (debounce sliders / typed amounts), clear() goes back to the default (status Active, group sale) Keys and values for your own pills: group "sale" | "rent" (set switches type to Residential / Residential Lease and clears subtypes), status "Active" (default) | "Pending" | "Closed", type a usePropertySearch().propertyTypes[].type ("Residential", "Residential Lease", "Residential Income", "Land", "Commercial Sale"…: set it with search({ type, subtypes: [] })), subtypes string[] of that type's subtypes[].value ("Condominium", "Single Family Residence", "Townhouse"…), minprice / maxprice numbers (USD; rent: per month), minbeds / minbaths numbers (maxbeds, maxbaths too), sort one of SEARCH_SORT_OPTIONS[].value.

Server data

Loaders run in a Server Component and come from @pandaidx/widgets/server. Typed data is for a page you design yourself, rendered inside the root.

fetchPropertySearchBootstrap()widget loader

First page of listings + public application for the current URL (call in the page Server Component). Returns PropertySearchBootstrap → initialData

getSearchResults()typed data

Typed server search for listings you lay out yourself (a neighborhood landing, a "homes under $1M" strip, a static grid): same params as fetchPropertySearchBootstrap (query for a fixed search, searchParams, pageSize, sort). Render the cards your own way (keep each courtesy line) inside <PropertySearchRoot initialData={data.bootstrap}> with <SearchDisclaimers />, and link each card to listing.path.