Search → select → preview-on-map → confirm flow. Composes autocomplete-location with the mapcn <Map> primitive and a Mapbase boundary fetch.
bunx shadcn@latest add https://mapbase.dev/r/address-picker.jsonThe shadcn CLI copies the file(s) below into your tree, installs npm dependencies, and recursively pulls registry dependencies (including from cross-registry URLs).
"use client"
import * as React from "react"
import { Mapbase, type LocationSuggestion, type Boundary } from "mapbase"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Map,
MapControls,
MapMarker,
MarkerContent,
type MapViewport,
} from "@/components/ui/map"
import { AutocompleteLocation } from "@/components/mapbase/autocomplete-location"
import { cn } from "@/lib/utils"
export type AddressPickerSelection = {
location: LocationSuggestion
/**
* Boundary GeoJSON if the engine returned one. `null` while loading
* and after a `not_implemented` response (boundaries are part of the
* roadmap; the picker still works without them).
*/
boundary: Boundary | null
}
export type AddressPickerProps = {
apiKey: string
baseUrl?: string
/**
* Initial map viewport. Defaults to roughly the Iberian peninsula —
* the only region currently covered by the engine.
*/
initialViewport?: Partial<MapViewport>
/** Fired when the user clicks "Use this location". */
onConfirm?: (selection: AddressPickerSelection) => void
/** Fired on every selection change (auto-pan, etc). */
onSelect?: (selection: AddressPickerSelection | null) => void
className?: string
}
/**
* Search-→-select-→-preview-→-confirm address flow built from
* three primitives: shadcn Card, mapcn Map, and the Mapbase
* AutocompleteLocation component. Drops into a checkout / onboarding
* flow as a single block.
*/
export function AddressPicker({
apiKey,
baseUrl,
initialViewport,
onConfirm,
onSelect,
className,
}: AddressPickerProps) {
const client = React.useMemo(
() => new Mapbase(apiKey, baseUrl ? { baseUrl } : undefined),
[apiKey, baseUrl],
)
const [location, setLocation] = React.useState<LocationSuggestion | null>(null)
const [boundary, setBoundary] = React.useState<Boundary | null>(null)
const [boundaryLoading, setBoundaryLoading] = React.useState(false)
const [viewport, setViewport] = React.useState<MapViewport>({
center: initialViewport?.center ?? [-4.5, 40.0],
zoom: initialViewport?.zoom ?? 4.5,
bearing: initialViewport?.bearing ?? 0,
pitch: initialViewport?.pitch ?? 0,
})
// Fetch the matching boundary (if any) and re-frame the map on every
// new selection. The boundaries route may legitimately return
// `not_implemented`; treat that the same as "no boundary available".
React.useEffect(() => {
if (!location) {
setBoundary(null)
setBoundaryLoading(false)
return
}
const controller = new AbortController()
setBoundaryLoading(true)
void (async () => {
const result = await client.boundaries.byId(location.id, {
signal: controller.signal,
})
if (controller.signal.aborted) return
const nextBoundary = result.error ? null : result.data
setBoundary(nextBoundary)
setBoundaryLoading(false)
const [lng, lat] = location.centroid
setViewport((prev) => ({
...prev,
center: [lng, lat],
zoom: zoomForKind(location.kind),
}))
onSelect?.({ location, boundary: nextBoundary })
})()
return () => controller.abort()
}, [client, location, onSelect])
const handleConfirm = () => {
if (!location) return
onConfirm?.({ location, boundary })
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle>Pick an address</CardTitle>
<CardDescription>
Search a city, district, or neighborhood. We'll show it on the
map so you can confirm.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AutocompleteLocation
apiKey={apiKey}
baseUrl={baseUrl}
value={location}
onChange={setLocation}
/>
<div className="relative h-[320px] w-full overflow-hidden rounded-md border">
<Map
viewport={viewport}
onViewportChange={setViewport}
className="h-full w-full"
>
<MapControls position="bottom-right" showZoom />
{location ? (
<MapMarker
longitude={location.centroid[0]}
latitude={location.centroid[1]}
>
<MarkerContent>
<div className="size-3 rounded-full bg-primary ring-2 ring-background shadow-md" />
</MarkerContent>
</MapMarker>
) : null}
</Map>
{boundaryLoading ? (
<div className="absolute right-2 top-2 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground backdrop-blur">
Loading boundary…
</div>
) : null}
</div>
{location ? (
<div className="rounded-md border bg-muted/40 p-3 text-sm">
<div className="font-medium">{location.name}</div>
<div className="text-muted-foreground">{location.breadcrumb}</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>id</span>
<span className="font-mono">{location.id}</span>
<span>kind</span>
<span>{location.kind}</span>
<span>centroid</span>
<span className="font-mono">
{location.centroid[0].toFixed(4)},{" "}
{location.centroid[1].toFixed(4)}
</span>
<span>boundary</span>
<span>{boundary ? "geometry available" : "—"}</span>
</div>
</div>
) : null}
</CardContent>
<CardFooter className="justify-end">
<Button onClick={handleConfirm} disabled={!location}>
Use this location
</Button>
</CardFooter>
</Card>
)
}
function zoomForKind(kind: LocationSuggestion["kind"]): number {
switch (kind) {
case "country":
return 5
case "province":
return 8
case "town":
return 11
case "district":
return 13
case "neighborhood":
return 14
default:
return 12
}
}