PropertyMapSearch
v1.0.7Updated 19 Oct 2026Map-and-list property search: a scrollable list of estate cards (photo, name, baht price range, land and floor area, bedrooms) beside a clustered-marker map, with filters and load-more, and ONE selection both panes obey (fb_73127df0). The component owns the list, the filters, the selection syncing and the clustering MATH; the BASE MAP comes from the host through renderMap, because every real tile provider needs an API key and a billing account the consumer owns - so nothing here adds a paid dependency. With no renderMap the markers sit on a plain themed surface that still zooms, selects and clusters.
Preview
Live · 15 stories▸Why this is recommendedmapmarkerclusterproperty+7
The default, out-of-the-box PropertyMapSearch.
- ✓A property or housing-estate search with a map beside the list
- ✓Show results as clustered pins and keep the list in step with the map
- ✓Hover a card and highlight its marker (and the other way round)
- ✓A store or branch locator with filters and a result list
- ✓Any browse screen where the rows have coordinates and the user picks one of them
- Two panes, one selection: click a card and its marker lifts and the view pans to it; click a marker and its card scrolls into view and is announced through a polite live region
- Clustering is our own pure math, not a library: clusterGeoPoints lays a pixel grid in Web Mercator space, is deterministic (same input, same bubble ids) and runs on a server too - importable from lib/map-cluster so a viewport endpoint can CALL it
- clusterFn replaces the clustering entirely for a host that already clusters (Google MarkerClusterer, supercluster, or its own grouping)
- The map surface is a slot: renderMap receives clusters, bounds, zoom, measured size, project(), setBounds(), selectProperty(), activateCluster() and renderMarkers(), so a host draws Google / Mapbox / MapLibre tiles and keeps our accessible marker layer, or places its own markers from the same clusters
- Every marker is a real button with a roving tab stop: arrows, Home and End walk the map in reading order, Enter selects, and a bubble of many zooms into its own bounds instead of guessing which listing was meant
- The list is ONE tab stop too, with arrow-key navigation - 6,000 cards must not bury everything after them in the tab order
- Filters built in: free text, property type chips, province, district (narrowed to the chosen province), a budget range and bedroom chips; filterProperties is pure and exported, so the same rule runs on the server
- A budget matches a listing whose PRICE RANGE overlaps it, and a listing with no price drops out only once a bound is set
- Load-more on scroll (IntersectionObserver) with a real button beside it, because an observer is not reachable from a keyboard
- States: loading skeletons in both panes, an empty catalogue, a filtered-to-zero result that names the filters and offers Clear, an alert-role error with retry, and a count of results that fall outside the current map view
- Phone layout: below lg the panes swap behind a List / Map switch (the hidden pane leaves the tab order), above it they sit side by side with the map sticky
- Consumer safety: a photo src and a card href are judged by allow-lists before they reach the DOM (relative or http(s) only by default, overridable per host), photos load lazily with referrerpolicy=no-referrer, names are text and never markup, and a coordinate that is not finite and in range is dropped instead of drawn at Null Island
- i18n labels prop (PropertyMapSearchLabels) with English defaults and {count} / {name} templates; prices, areas and counts format through the locale provider
- Controlled or uncontrolled filters, selection, hovered id, map bounds and pane view
- ✕A map with no list beside it - this component owns the list
- ✕Drawing the base map itself: it has no tile layer and no map SDK, by design (pass renderMap)
- ✕Driving directions, routing or geocoding
- ✕A plain filterable table with no geography (use DataTable with its filter panel)
- ✕A single location pin on a contact page
Install
Ask your agent, or call the MCP tool directly. The resolved bundle is written into your project.
Usage
Tick variants in the left menu — each adds a tab with that variant’s real story source, the same code the preview above renders.
Props
itemsThe listings: { id, name, lat, lng, photoUrl?, photoAlt?, priceMin?, priceMax?, currency?, landArea*?, floorArea*?, bedrooms?, propertyType?, province?, district?, developer?, badge?, href? }. A row with an unusable coordinate still appears in the list; it is simply not drawn on the map
yesrenderMapDraw the base map. The surface carries items, visibleItems, clusters, bounds, zoom, size, selectedId, hoveredId, selectProperty, hoverProperty, activateCluster, setBounds, fitToResults, project and renderMarkers. Without it the markers sit on a plain themed panel that says it has no base map
—clusterFnReplace the clustering. Default: the pure grid clusterer from lib/map-cluster
—clusterRadiusCell size in screen pixels for the default clusterer. Default 64 - a 40px bubble plus breathing room
—filtersControlled filter state: { query, types, province, districts, priceMin, priceMax, bedrooms }
—defaultFiltersUncontrolled initial filters
—onFiltersChangeFires with the full next filter set after every change - put it in the URL
—showFiltersRender the built-in filter row. Default true; false hands the filter UI to the host
—filterOptionsMenus for the filters: { types, provinces, districts, priceSteps, bedrooms }. Anything omitted is derived from the listings, so passing rows alone still gives working filters. A 4+ bedroom chip says what it means: { value: 4, label: "4+", counts: [4,5,6] }
—serverFilteredThe rows are already filtered by the server; the filter row still renders and still reports through onFiltersChange
—selectedIdControlled selection - the listing both panes show as chosen
—defaultSelectedIdUncontrolled initial selection
—onSelectedIdChangeFires on every selection, from either pane, including clearing it
—hoveredIdControlled hover highlight, for syncing a third surface
—onHoveredIdChangeFires when a card or a marker is hovered or left
—onOpenPropertyFires when a card details link is used, alongside its href
—boundsControlled map viewport { north, south, east, west }
—defaultBoundsUncontrolled initial viewport. With neither, the view follows the results
—onBoundsChangeFires on zoom, fit, a cluster zoom, or a pan a host reports through setBounds
—loadingSkeleton cards and a veiled map while the search is in flight
—errorA failed search. true uses the built-in copy; a node replaces the message
—onRetryRetry handler on the error state. With none, no retry button is offered
—hasMoreMore pages exist - renders the load-more control
—onLoadMoreFetch the next page
—loadingMoreThe next page is in flight
—autoLoadMoreLoad the next page when the sentinel scrolls into view. Default true once onLoadMore is given; the button stays either way
—emptyStateReplaces the built-in nothing-here-yet panel
—filteredEmptyStateReplaces the built-in no-results-for-these-filters panel, which is a different screen from an empty catalogue
—imageSrcAllowedMay this photo URL load? Default isSafePropertyImageSrc: relative or http(s) only, so a data: or javascript: URL in a row cannot reach the reader. Widen it deliberately for blob: or data: photos the host creates itself
—hrefAllowedMay this card link there? Default isSafePropertyHref: relative or http(s) only - a javascript: href a seller typed is stored XSS
—formatPriceFormat the price range on a card and a marker. Default: compact currency through the locale provider, or the price-on-request label
—showMarkerPricePrint the price on single-result markers. Default true; false uses a pin glyph
—mapHeightHeight of the map pane. Default: 26rem on phones, 34rem from lg up
—viewControlled pane for narrow screens
—defaultViewWhich pane a narrow screen opens on. Default list
—onViewChangeFires when the List / Map switch is used
—labelsOverride every built-in string and aria-label (i18n). Merged over DEFAULT_PROPERTY_MAP_SEARCH_LABELS; the defaults are English
—