# P autocomplete 16

> Address autocomplete with Google Maps Places API

A shadcn/ui block from the [@coss](https://coss.com/ui) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/coss/p-autocomplete-16).

- Page, with a live preview: https://shadcn.exchange/r/coss/p-autocomplete-16
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/coss/p-autocomplete-16-light-a71e13e4acf499c4.png
- Registry item: https://coss.com/ui/r/p-autocomplete-16.json
- Categories: autocomplete, input, async, search
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://coss.com/ui/r/p-autocomplete-16.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `@coss/autocomplete`, `@coss/spinner`

## Files

### `registry/default/particles/p-autocomplete-16.tsx`

```tsx
"use client";

import { MapPinIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import {
  Autocomplete,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
  AutocompleteStatus,
} from "@/registry/default/ui/autocomplete";
import { Spinner } from "@/registry/default/ui/spinner";

// Set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY with the Places API (New) enabled to fetch
// live suggestions. Without a key, the demo falls back to sample addresses.
const GOOGLE_MAPS_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? "";

type AddressSuggestion = {
  placeId: string;
  text: string;
  mainText: string;
  secondaryText: string;
};

const sampleAddresses: AddressSuggestion[] = [
  {
    mainText: "1600 Amphitheatre Parkway",
    secondaryText: "Mountain View, CA 94043, USA",
  },
  {
    mainText: "1600 Pennsylvania Avenue NW",
    secondaryText: "Washington, DC 20500, USA",
  },
  { mainText: "350 Fifth Avenue", secondaryText: "New York, NY 10118, USA" },
  {
    mainText: "221B Baker Street",
    secondaryText: "London NW1 6XE, United Kingdom",
  },
  {
    mainText: "Champ de Mars, 5 Avenue Anatole France",
    secondaryText: "75007 Paris, France",
  },
  {
    mainText: "Piazza del Colosseo, 1",
    secondaryText: "00184 Roma RM, Italy",
  },
  { mainText: "Platz der Republik 1", secondaryText: "11011 Berlin, Germany" },
  {
    mainText: "1 Macquarie Street",
    secondaryText: "Sydney NSW 2000, Australia",
  },
].map((address, index) => ({
  ...address,
  placeId: `sample-${index + 1}`,
  text: `${address.mainText}, ${address.secondaryText}`,
}));

// A short-lived session token groups Autocomplete + Place Details requests
// for billing.
function newSessionToken() {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return crypto.randomUUID();
  }
  return Math.random().toString(36).slice(2);
}

type PlacesAutocompleteResponse = {
  suggestions?: {
    placePrediction?: {
      placeId?: string;
      text?: { text?: string };
      structuredFormat?: {
        mainText?: { text?: string };
        secondaryText?: { text?: string };
      };
    };
  }[];
};

async function fetchAddressSuggestions(
  query: string,
  sessionToken: string,
  signal: AbortSignal,
): Promise<AddressSuggestion[]> {
  const response = await fetch(
    "https://places.googleapis.com/v1/places:autocomplete",
    {
      body: JSON.stringify({ input: query, sessionToken }),
      headers: {
        "Content-Type": "application/json",
        "X-Goog-Api-Key": GOOGLE_MAPS_API_KEY,
      },
      method: "POST",
      signal,
    },
  );
  if (!response.ok) {
    throw new Error("Places API request failed");
  }
  const data = (await response.json()) as PlacesAutocompleteResponse;
  const suggestions: AddressSuggestion[] = [];
  for (const suggestion of data.suggestions ?? []) {
    const prediction = suggestion.placePrediction;
    if (!prediction?.placeId) continue;
    const text = prediction.text?.text ?? "";
    suggestions.push({
      mainText: prediction.structuredFormat?.mainText?.text ?? text,
      placeId: prediction.placeId,
      secondaryText: prediction.structuredFormat?.secondaryText?.text ?? "",
      text,
    });
  }
  return suggestions;
}

async function searchSampleAddresses(
  query: string,
): Promise<AddressSuggestion[]> {
  await new Promise((resolve) =>
    setTimeout(resolve, Math.random() * 500 + 100),
  );
  const lowerQuery = query.toLowerCase();
  return sampleAddresses.filter((address) =>
    address.text.toLowerCase().includes(lowerQuery),
  );
}

export default function Particle() {
  const [searchValue, setSearchValue] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
  const [error, setError] = useState<string | null>(null);
  const sessionTokenRef = useRef<string | null>(null);

  useEffect(() => {
    const query = searchValue.trim();
    if (!query) {
      setSuggestions([]);
      setIsLoading(false);
      setError(null);
      return;
    }

    setIsLoading(true);
    setError(null);
    let ignore = false;
    const controller = new AbortController();

    const timeoutId = setTimeout(async () => {
      try {
        sessionTokenRef.current ??= newSessionToken();
        const results = GOOGLE_MAPS_API_KEY
          ? await fetchAddressSuggestions(
              query,
              sessionTokenRef.current,
              controller.signal,
            )
          : await searchSampleAddresses(query);
        if (!ignore) setSuggestions(results);
      } catch {
        if (!ignore && !controller.signal.aborted) {
          setError("Could not load address suggestions. Please try again.");
          setSuggestions([]);
        }
      } finally {
        if (!ignore) setIsLoading(false);
      }
    }, 300);

    return () => {
      ignore = true;
      controller.abort();
      clearTimeout(timeoutId);
    };
  }, [searchValue]);

  let status: ReactNode = `${suggestions.length} suggestion${suggestions.length === 1 ? "" : "s"} found`;
  if (isLoading) {
    status = (
      <span className="flex items-center justify-between gap-2 text-muted-foreground">
        Searching addresses...
        <Spinner className="size-4.5 sm:size-4" />
      </span>
    );
  } else if (error) {
    status = (
      <span className="font-normal text-destructive text-sm">{error}</span>
    );
  } else if (suggestions.length === 0 && searchValue) {
    status = (
      <span className="font-normal text-muted-foreground text-sm">
        No addresses found for "{searchValue}"
      </span>
    );
  }

  const shouldRenderPopup = searchValue.trim() !== "";

  return (
    <Autocomplete
      autoHighlight
      filter={null}
      items={suggestions}
      itemToStringValue={(item: unknown) => (item as AddressSuggestion).text}
      onValueChange={(value, eventDetails) => {
        setSearchValue(value);
        if (eventDetails.reason === "item-press") {
          // Selecting a suggestion ends the billing session. In a real app,
          // fetch the place details with the same token before resetting it.
          sessionTokenRef.current = null;
        }
      }}
      value={searchValue}
    >
      <AutocompleteInput
        aria-label="Address"
        autoComplete="off"
        className="min-w-0 *:[input]:truncate"
        placeholder="Enter an address"
        startAddon={<MapPinIcon />}
      />
      {shouldRenderPopup && (
        <AutocompletePopup
          aria-busy={isLoading || undefined}
          className="max-w-(--anchor-width) *:min-w-0"
        >
          <AutocompleteStatus className="text-muted-foreground">
            {status}
          </AutocompleteStatus>
          <AutocompleteList>
            {(suggestion: AddressSuggestion) => (
              <AutocompleteItem key={suggestion.placeId} value={suggestion}>
                <span className="flex w-full min-w-0 flex-col">
                  <span className="truncate font-medium">
                    {suggestion.mainText}
                  </span>
                  <span className="truncate text-muted-foreground text-xs">
                    {suggestion.secondaryText}
                  </span>
                </span>
              </AutocompleteItem>
            )}
          </AutocompleteList>
        </AutocompletePopup>
      )}
    </Autocomplete>
  );
}
```
