build69
build69  /  Components  /  Page Structure  /  PropertyMapSearch

PropertyMapSearch

v1.0.7Updated 19 Oct 2026

Map-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
DefaultOpen ↗
▸Why this is recommendedmapmarkerclusterproperty+7

The default, out-of-the-box PropertyMapSearch.

Recommended for+14
  • ✓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
Tags+6
mapmarkerclusterpropertyreal-estatelistingsearchgeolocationlist-and-mapdefault
Features+4
  • 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
Not recommended for−2
  • ✕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.

get_registry_item({ name: "PropertyMapSearch" })

Usage

import { PropertyMapSearch, type PropertyListing } from '@/components/patterns/property-map-search';

const LISTINGS: PropertyListing[] = [
  {
    id: 'baan-suan-lada',
    name: 'Baan Suan Lada',
    lat: 13.6695, lng: 100.6032,
    priceMin: 4900000, priceMax: 7200000, currency: 'THB',
    landAreaMin: 52, landAreaMax: 84, landAreaUnit: 'sq. wah',
    bedrooms: [3, 4], propertyType: 'house',
    province: 'Bangkok', district: 'Bang Na',
    photoUrl: '/photos/lada.jpg', href: '/projects/lada',
  },
];

// The host owns the base map; the component owns the list, the filters and the clustering.
<PropertyMapSearch
  items={LISTINGS}
  onSelectedIdChange={setSelectedId}
  renderMap={(surface) => (
    <MyTileMap bounds={surface.bounds} onMove={surface.setBounds}>
      {surface.renderMarkers()}
    </MyTileMap>
  )}
/>;

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

PropTypeRequired
items

The 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

PropertyListing[]yes
renderMap

Draw 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

(surface: PropertyMapSurface) => React.ReactNode—
clusterFn

Replace the clustering. Default: the pure grid clusterer from lib/map-cluster

ClusterFn—
clusterRadius

Cell size in screen pixels for the default clusterer. Default 64 - a 40px bubble plus breathing room

number—
filters

Controlled filter state: { query, types, province, districts, priceMin, priceMax, bedrooms }

PropertyFilters—
defaultFilters

Uncontrolled initial filters

PropertyFilters—
onFiltersChange

Fires with the full next filter set after every change - put it in the URL

(filters: PropertyFilters) => void—
showFilters

Render the built-in filter row. Default true; false hands the filter UI to the host

boolean—
filterOptions

Menus 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] }

PropertyFilterOptions—
serverFiltered

The rows are already filtered by the server; the filter row still renders and still reports through onFiltersChange

boolean—
selectedId

Controlled selection - the listing both panes show as chosen

string | null—
defaultSelectedId

Uncontrolled initial selection

string | null—
onSelectedIdChange

Fires on every selection, from either pane, including clearing it

(id: string | null) => void—
hoveredId

Controlled hover highlight, for syncing a third surface

string | null—
onHoveredIdChange

Fires when a card or a marker is hovered or left

(id: string | null) => void—
onOpenProperty

Fires when a card details link is used, alongside its href

(item: PropertyListing) => void—
bounds

Controlled map viewport { north, south, east, west }

MapBounds—
defaultBounds

Uncontrolled initial viewport. With neither, the view follows the results

MapBounds—
onBoundsChange

Fires on zoom, fit, a cluster zoom, or a pan a host reports through setBounds

(bounds: MapBounds) => void—
loading

Skeleton cards and a veiled map while the search is in flight

boolean—
error

A failed search. true uses the built-in copy; a node replaces the message

React.ReactNode | boolean—
onRetry

Retry handler on the error state. With none, no retry button is offered

() => void—
hasMore

More pages exist - renders the load-more control

boolean—
onLoadMore

Fetch the next page

() => void—
loadingMore

The next page is in flight

boolean—
autoLoadMore

Load the next page when the sentinel scrolls into view. Default true once onLoadMore is given; the button stays either way

boolean—
emptyState

Replaces the built-in nothing-here-yet panel

React.ReactNode—
filteredEmptyState

Replaces the built-in no-results-for-these-filters panel, which is a different screen from an empty catalogue

React.ReactNode—
imageSrcAllowed

May 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

(src: string) => boolean—
hrefAllowed

May this card link there? Default isSafePropertyHref: relative or http(s) only - a javascript: href a seller typed is stored XSS

(href: string) => boolean—
formatPrice

Format the price range on a card and a marker. Default: compact currency through the locale provider, or the price-on-request label

(item: PropertyListing) => string—
showMarkerPrice

Print the price on single-result markers. Default true; false uses a pin glyph

boolean—
mapHeight

Height of the map pane. Default: 26rem on phones, 34rem from lg up

number | string—
view

Controlled pane for narrow screens

'list' | 'map'—
defaultView

Which pane a narrow screen opens on. Default list

'list' | 'map'—
onViewChange

Fires when the List / Map switch is used

(view: 'list' | 'map') => void—
labels

Override every built-in string and aria-label (i18n). Merged over DEFAULT_PROPERTY_MAP_SEARCH_LABELS; the defaults are English

Partial<PropertyMapSearchLabels>—

Related