MAPBASE.DEV
PlaygroundDataAPISDKPricing
Login
PlaygroundDataAPISDKPricing
LoginGet API key
All blocks

primitives

Autocomplete

Self-contained search against Mapbase registry layers (locations, postcodes, lau, zones). On select, returns the full engine hit in ref. Also supports Google and Geoapify sources.

Open playground →

Live preview

Install

$bunx shadcn@latest add https://mapbase.dev/r/autocomplete.json

Copies source into your app and installs npm + registry dependencies.

npm dependencies

mapbasemotionlucide-react@tanstack/react-query

Registry dependencies

commandpopoverbutton

Source

components/mapbase/autocomplete.tsx
"use client"

import * as React from "react"
import { motion } from "motion/react"
import {
  DEFAULT_AUTOCOMPLETE_INCLUDE,
  type AutocompleteHit,
  type AutocompleteOutcome,
  type CountryCode,
  type GeometryInclude,
  type Layer,
  type LocationKind,
  type LocationSuggestion,
  type PlaceSourceName,
  type PlaceSuggestion,
  MapbaseError,
} from "mapbase"
import { useAutocomplete } from "mapbase/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"

import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { Loader2, Search, X } from "lucide-react"

export type AutocompleteProps = {
  apiKey?: string
  baseUrl?: string
  source?: PlaceSourceName
  country?: CountryCode
  layers?: Layer[]
  limit?: number
  debounceMs?: number
  minChars?: number
  lat?: number
  lng?: number
  include?: GeometryInclude[]
  language?: string
  types?: string
  radius?: number
  strictbounds?: boolean
  region?: string
  geoapifyType?: import("mapbase").GeoapifyAutocompleteParams["type"]
  geoapifyLang?: string
  geoapifyFilter?: string
  geoapifyBias?: string
  googleApiKey?: string
  geoapifyApiKey?: string
  value?: PlaceSuggestion | null
  onChange?: (suggestion: PlaceSuggestion | null) => void
  onSelect?: (suggestion: PlaceSuggestion) => void
  onHoverChange?: (hit: AutocompleteHit | null) => void
  onResponse?: (payload: {
    outcome: AutocompleteOutcome | null
    latencyMs: number | null
  }) => void
  placeholder?: string
  triggerLabel?: string
  disabled?: boolean
  className?: string
  detail?: "compact" | "full"
  /** Inline search bar (map overlay) vs popover trigger (forms). */
  variant?: "popover" | "inline"
  /** Endpoint path badge shown in inline mode (e.g. /v1/autocomplete). */
  requestPath?: string
  /** Dropdown placement for inline mode (use top for bottom-docked search bars). */
  resultsSide?: "top" | "bottom"
  activeId?: string | null
  blurb?: string
}

const LOCATION_KIND_LABELS = {
  autonomous_city: "Autonomous city",
  autonomous_region: "Autonomous region",
  comarca: "Comarca",
  continent: "Continent",
  country: "Country",
  district: "District",
  external_region: "External region",
  island: "Island",
  municipality: "Municipality",
  neighborhood: "Neighborhood",
  parish: "Parish",
  province: "Province",
  town: "Town",
} as const satisfies Record<LocationKind, string>

const KIND_COLORS: Record<string, string> = {
  autonomous_city: "#6366f1",
  autonomous_region: "#818cf8",
  comarca: "#a78bfa",
  continent: "#1d4ed8",
  country: "#2563eb",
  district: "#0891b2",
  external_region: "#7c3aed",
  island: "#0d9488",
  municipality: "#8b5cf6",
  neighborhood: "#ec4899",
  parish: "#d946ef",
  province: "#4f46e5",
  town: "#a855f7",
  lau: "#65a30d",
  postcode: "#0284c7",
  zone: "#d97706",
  region: "#ca8a04",
  beach: "#14b8a6",
  locality: "#8b5cf6",
  administrative_area_level_1: "#4f46e5",
  administrative_area_level_2: "#6366f1",
  street_address: "#64748b",
  route: "#64748b",
}

const KIND_COLOR_FALLBACK = "#94a3b8"

/** Keep address search from pairing with nearby password fields in the browser. */
const SEARCH_INPUT_PROPS = {
  autoComplete: "off",
  autoCorrect: "off",
  autoCapitalize: "off",
  spellCheck: false,
  "data-1p-ignore": true,
  "data-lpignore": "true",
  "data-form-type": "other",
} as const

function kindBadgeStyle(kind: string | undefined): {
  borderColor: string
  backgroundColor: string
  color: string
} {
  const fill = (kind ? KIND_COLORS[kind] : undefined) ?? KIND_COLOR_FALLBACK
  return {
    borderColor: `${fill}4D`,
    backgroundColor: `${fill}1A`,
    color: fill,
  }
}

function humanizeKind(kind: string | undefined): string {
  if (!kind) return "—"
  const labels = LOCATION_KIND_LABELS as Record<string, string>
  const known = labels[kind]
  if (known) return known
  return kind
    .split("_")
    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
    .join(" ")
}

function useReducedMotion(): boolean {
  const [reduced, setReduced] = React.useState(false)

  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(mq.matches)
    update()
    mq.addEventListener("change", update)
    return () => mq.removeEventListener("change", update)
  }, [])

  return reduced
}

function suggestionRow(value: PlaceSuggestion): LocationSuggestion | undefined {
  return value.ref as LocationSuggestion | undefined
}

function hitToSuggestion(hit: AutocompleteHit): PlaceSuggestion {
  return {
    id: hit.id,
    label: hit.label,
    ref: hit.ref ?? hit,
  }
}

// function hitContext(hit: AutocompleteHit): string {
//   const row = hit.ref as LocationSuggestion | undefined
//   return row?.label ?? row?.name ?? hit.country ?? ""
// }

function HitRow({
  hit,
  // detail,
}: {
  hit: AutocompleteHit
  detail: "compact" | "full"
}) {
  // const context = hitContext(hit)
  return (
    <>
      <span className="flex min-w-0 flex-1 items-center gap-2">
        <span className="truncate text-[14px] text-foreground">
          {hit.label}
        </span>
        {/* {detail === "full" && context ? (
          <span className="truncate text-[12px] text-muted-foreground">
            {context}
          </span>
        ) : null} */}
      </span>
      {hit.kind ? (
        <span
          className="shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium"
          style={kindBadgeStyle(hit.kind)}
        >
          {humanizeKind(hit.kind)}
        </span>
      ) : null}
    </>
  )
}

function KindBadge({ kind }: { kind?: string }) {
  if (!kind) return null
  return (
    <span
      className="shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium"
      style={kindBadgeStyle(kind)}
    >
      {humanizeKind(kind)}
    </span>
  )
}

export function Autocomplete(props: AutocompleteProps) {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: { staleTime: 0, refetchOnWindowFocus: false },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <AutocompleteInner {...props} />
    </QueryClientProvider>
  )
}

function AutocompleteInner({
  apiKey,
  baseUrl,
  source,
  country,
  layers,
  limit,
  debounceMs,
  minChars,
  lat,
  lng,
  include = DEFAULT_AUTOCOMPLETE_INCLUDE,
  language,
  types,
  radius,
  strictbounds,
  region,
  geoapifyType,
  geoapifyLang,
  geoapifyFilter,
  geoapifyBias,
  googleApiKey,
  geoapifyApiKey,
  value,
  onChange,
  onSelect,
  onHoverChange,
  onResponse,
  placeholder = "Search a city, district, neighborhood…",
  triggerLabel = "Pick a location",
  disabled,
  className,
  detail = "full",
  variant = "inline",
  requestPath = "/v1/autocomplete",
  resultsSide = "bottom",
  activeId = null,
  blurb,
}: AutocompleteProps) {
  const [open, setOpen] = React.useState(false)
  const [focused, setFocused] = React.useState(false)
  const reducedMotion = useReducedMotion()
  const { ref: anchorRef, width: anchorWidth } =
    useElementWidth<HTMLDivElement>()
  const minQueryChars = minChars ?? 2
  const resultsPopoverStyle =
    anchorWidth > 0 ? ({ width: anchorWidth } as const) : undefined

  const {
    query,
    setQuery,
    hits,
    loading,
    error,
    response,
    request,
    latencyMs,
  } = useAutocomplete({
    apiKey,
    baseUrl,
    source,
    debounceMs,
    minChars,
    country,
    layers,
    limit,
    lat,
    lng,
    include,
    language,
    types,
    radius,
    strictbounds,
    region,
    geoapifyType,
    geoapifyLang,
    geoapifyFilter,
    geoapifyBias,
    googleApiKey,
    geoapifyApiKey,
  })

  const onResponseRef = React.useRef(onResponse)
  onResponseRef.current = onResponse

  const lastResponseKey = React.useRef("")

  React.useEffect(() => {
    const notify = onResponseRef.current
    if (!notify) return

    const payload =
      !response && !request
        ? {
            outcome: null as AutocompleteOutcome | null,
            latencyMs: null as number | null,
          }
        : {
            outcome: response
              ? {
                  hits,
                  response,
                  request: request ?? {
                    method: "GET" as const,
                    path: "",
                    params: {},
                  },
                }
              : null,
            latencyMs,
          }

    const key = JSON.stringify({
      latencyMs: payload.latencyMs,
      hitIds: payload.outcome?.hits.map((h: AutocompleteHit) => h.id) ?? null,
      path: payload.outcome?.request.path ?? null,
    })
    if (key === lastResponseKey.current) return
    lastResponseKey.current = key

    notify(payload)
  }, [hits, response, request, latencyMs])

  const handleSelect = (suggestion: PlaceSuggestion | null) => {
    onHoverChange?.(null)
    onChange?.(suggestion)
    if (suggestion) onSelect?.(suggestion)
    if (variant === "popover") setOpen(false)
    setQuery("")
  }

  const showResults =
    variant === "inline"
      ? focused &&
        (query.length >= minQueryChars || hits.length > 0 || Boolean(error))
      : open

  const selectedRow = value ? suggestionRow(value) : undefined
  const selectedBadge =
    value && detail === "full" ? <KindBadge kind={selectedRow?.kind} /> : null

  const resultsList = (
    <>
      {loading ? (
        <div className="py-6 text-center text-sm text-muted-foreground">
          Searching…
        </div>
      ) : null}
      {!loading && error ? (
        <div className="flex flex-col gap-1 p-3 text-sm">
          <span className="font-medium text-red-400">
            {humanizeErrorCode(error.code)}
          </span>
          <span className="text-white/50">{error.message}</span>
        </div>
      ) : null}
      {!loading && !error && query.trim().length < minQueryChars ? (
        <CommandEmpty>Type at least {minQueryChars} characters.</CommandEmpty>
      ) : null}
      {!loading &&
      !error &&
      query.trim().length >= minQueryChars &&
      hits.length === 0 ? (
        <CommandEmpty>No results.</CommandEmpty>
      ) : null}
      {!error && hits.length > 0 ? (
        <CommandGroup>
          {hits.map((hit: AutocompleteHit, i: number) => (
            <motion.div
              key={hit.id}
              className="w-full"
              initial={reducedMotion ? false : { opacity: 0, x: -6 }}
              animate={{ opacity: 1, x: 0 }}
              transition={{ delay: reducedMotion ? 0 : i * 0.03 }}
            >
              <CommandItem
                value={hit.id}
                onSelect={() => handleSelect(hitToSuggestion(hit))}
                onMouseDown={(e) => {
                  e.preventDefault()
                  e.stopPropagation()
                }}
                onClick={(e) => e.stopPropagation()}
                onMouseEnter={() => onHoverChange?.(hit)}
                onMouseLeave={() => onHoverChange?.(null)}
                className={cn(
                  "flex w-full items-center justify-between rounded-lg px-3 py-2.5",
                  activeId === hit.id && "bg-accent"
                )}
              >
                <HitRow hit={hit} detail={detail} />
              </CommandItem>
            </motion.div>
          ))}
        </CommandGroup>
      ) : null}
    </>
  )

  if (variant === "popover") {
    return (
      <div ref={anchorRef} className={cn("w-full min-w-0", className)}>
        <Popover open={open} onOpenChange={setOpen} modal>
          <PopoverTrigger asChild>
            <Button
              type="button"
              variant="outline"
              role="combobox"
              aria-expanded={open}
              disabled={disabled}
              className="relative w-full justify-between gap-2 text-left font-normal"
            >
              {value ? (
                <>
                  <span className="truncate font-medium">{value.label}</span>
                  {selectedBadge}
                </>
              ) : (
                triggerLabel
              )}
            </Button>
          </PopoverTrigger>
          <PopoverContent
            align="start"
            style={resultsPopoverStyle}
            className="overflow-hidden p-0"
          >
            <Command
              shouldFilter={false}
              className="w-full border-0 bg-transparent"
            >
              <CommandInput
                placeholder={placeholder}
                value={query}
                onValueChange={setQuery}
                disabled={disabled}
                {...SEARCH_INPUT_PROPS}
              />
              <CommandList className="max-h-72">{resultsList}</CommandList>
            </Command>
          </PopoverContent>
        </Popover>
      </div>
    )
  }

  return (
    <div className={cn("relative w-full", className)}>
      <div
        ref={anchorRef}
        className={cn(
          "flex w-full items-center gap-3 rounded-xl border bg-card px-4 py-3 shadow-sm transition-[border-color,box-shadow]",
          focused
            ? "border-primary/50 ring-2 ring-primary/20"
            : "border-border",
        )}
      >
        <Search className="size-4 shrink-0 text-muted-foreground" />
        <input
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onFocus={() => setFocused(true)}
          onBlur={() => setTimeout(() => setFocused(false), 120)}
          placeholder={value ? value.label : placeholder}
          disabled={disabled}
          className="w-full min-w-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70"
          aria-label="Search places"
          aria-expanded={showResults}
          aria-autocomplete="list"
          role="combobox"
          {...SEARCH_INPUT_PROPS}
        />
        {selectedBadge}
        {loading ? (
          <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
        ) : null}
        {value ? (
          <button
            type="button"
            onClick={() => handleSelect(null)}
            className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
            aria-label="Clear selection"
          >
            <X className="size-3.5" />
          </button>
        ) : null}
        {requestPath ? (
          <kbd className="hidden shrink-0 rounded-md border border-border/80 bg-muted/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground sm:inline">
            {requestPath}
          </kbd>
        ) : null}
      </div>
      {showResults ? (
        <div
          className={cn(
            "absolute z-50 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
            resultsSide === "top" ? "bottom-full mb-2" : "top-full mt-2",
          )}
          style={resultsPopoverStyle}
        >
          <Command shouldFilter={false} className="w-full bg-transparent">
            <CommandList className="max-h-[min(38dvh,280px)] w-full sm:max-h-72">
              {resultsList}
            </CommandList>
          </Command>
        </div>
      ) : null}
      {blurb ? (
        <p className="mt-2 text-center text-xs text-muted-foreground">
          {blurb}
        </p>
      ) : null}
    </div>
  )
}

function useElementWidth<T extends HTMLElement>() {
  const ref = React.useRef<T>(null)
  const [width, setWidth] = React.useState(0)

  React.useLayoutEffect(() => {
    const el = ref.current
    if (!el) return
    const update = () => setWidth(el.getBoundingClientRect().width)
    update()
    const observer = new ResizeObserver(update)
    observer.observe(el)
    return () => observer.disconnect()
  }, [])

  return { ref, width }
}

function humanizeErrorCode(code: MapbaseError["code"]): string {
  switch (code) {
    case "unauthorized":
      return "Invalid API key"
    case "rate_limited":
      return "Rate limited"
    case "invalid_request":
      return "Invalid query"
    case "not_implemented":
      return "Not available"
    case "network_error":
      return "Network error"
    default:
      return "Something went wrong"
  }
}
MAPBASE.DEV

One foundation for geographic products — standalone, or a layer on top of the geocoder you already use.

Product

  • Pricing
  • Compare
  • Blocks

Developers

  • SDK
  • Docs (Scalar)
  • OpenAPI spec
  • LLM markdown ref

Project

  • Contact

Legal

© 2026 MAPBASE. All rights reserved.

Every location, every boundary - one API.