Build address autocomplete in JavaScript
This guide builds an address field that suggests addresses as people type, and fills in the rest of the form from the one they pick. It's plain JavaScript with no library, about 80 lines, and works with the keyboard and screen readers. Suggestions come from Mapricot's autocomplete API.
Everything goes in one HTML file, with no build step. It was tested in Chromium. The geocoding page has a live autocomplete demo to try.
Before you start
You need a browser key with the Places product. Getting started shows how to make one. Add http://localhost:* to its allowed origins, so it works from your computer while you build.
1. Make the page
Make a folder for the form, and in it a file called index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Address form</title>
<style>
form {
display: grid;
gap: 0.25rem;
max-width: 24rem;
font: 16px system-ui, sans-serif;
}
input {
width: 100%;
box-sizing: border-box;
padding: 0.5rem;
margin-bottom: 0.75rem;
}
.autocomplete {
position: relative;
}
#suggestions {
position: absolute;
top: 2.5rem;
left: 0;
right: 0;
z-index: 1;
margin: 0;
padding: 0;
list-style: none;
background: white;
border: 1px solid #ccc;
}
#suggestions li {
padding: 0.5rem;
cursor: pointer;
}
#suggestions li:hover,
#suggestions li[aria-selected="true"] {
background: #f1f3f5;
}
</style>
</head>
<body>
<form>
<label for="address">Address</label>
<div class="autocomplete">
<input id="address" name="address" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="suggestions" autocomplete="off">
<ul id="suggestions" role="listbox" aria-label="Suggested addresses" hidden></ul>
</div>
<label for="city">City</label>
<input id="city" name="city" autocomplete="address-level2">
<label for="region">State or region</label>
<input id="region" name="region" autocomplete="address-level1">
<label for="postalCode">Postal code</label>
<input id="postalCode" name="postalCode" autocomplete="postal-code">
<label for="country">Country</label>
<input id="country" name="country" autocomplete="country-name">
</form>
<script type="module">
const key = "mapricot_pk_…";
</script>
</body>
</html>
Put your key in place of mapricot_pk_…. Here's what each part does:
- The address field is a combobox: a text input that controls a list of suggestions. Its ARIA attributes tell screen readers that suggestions appear below it, and which one is highlighted.
- The list starts
hidden, and the script shows it when there are suggestions. - The other fields have
autocompletevalues, so browsers can still fill them from a saved address. The address field turns the browser's own suggestions off, since they'd cover the list. - The styles lay the form out in a column, and place the list over the fields below the address, with the highlighted suggestion shaded.
type="module"makes the script a JavaScript module, which runs after the page has loaded, so the form is there when the script looks for it.- The key sits in the page, where anyone can read it. That's what browser keys are for: they only work from the origins you allowed.
2. Open the page
Serve the folder with a web server, rather than opening the file directly. A page opened from a file has no origin, so the API refuses the key with 403 and "Key is not allowed from this origin." Any web server works. In the folder, run one of these:
npx serve
python3 -m http.server
npx serve needs Node.js, and serves the page at http://localhost:3000. Python's serves it at http://localhost:8000. Open the address to see the form.
3. Add suggestions
Add this to the script, after the key:
const form = document.querySelector("form");
const input = document.querySelector("#address");
const list = document.querySelector("#suggestions");
let suggestions = [];
let active = -1;
let controller;
let timer;
async function suggest() {
controller?.abort();
const q = input.value.trim();
if (q.length < 3) return show([]);
controller = new AbortController();
try {
const response = await fetch(`https://api.mapricot.com/v1/autocomplete?${new URLSearchParams({ q, limit: 5 })}`, {
headers: { Authorization: `Bearer ${key}` },
signal: controller.signal,
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
const { results } = await response.json();
show(results.filter((result, index) => results.findIndex((other) => other.label === result.label) === index));
} catch (error) {
if (error.name !== "AbortError") show([]);
}
}
function show(items) {
suggestions = items;
active = -1;
list.replaceChildren(
...items.map((result, index) => {
const option = document.createElement("li");
option.id = `suggestion-${index}`;
option.setAttribute("role", "option");
option.setAttribute("aria-selected", "false");
option.textContent = result.label;
option.addEventListener("mousedown", (event) => {
event.preventDefault();
choose(index);
});
return option;
}),
);
list.hidden = !items.length;
input.setAttribute("aria-expanded", String(items.length > 0));
input.removeAttribute("aria-activedescendant");
}
function highlight(index) {
list.children[active]?.setAttribute("aria-selected", "false");
active = index;
list.children[active].setAttribute("aria-selected", "true");
input.setAttribute("aria-activedescendant", list.children[active].id);
}
function choose(index) {
const { label, address = {} } = suggestions[index];
input.value = [address.houseNumber, address.street].filter(Boolean).join(" ") || label;
form.elements.city.value = address.locality ?? "";
form.elements.region.value = address.region ?? "";
form.elements.postalCode.value = address.postalCode ?? "";
form.elements.country.value = address.country ?? "";
show([]);
}
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(suggest, 200);
});
input.addEventListener("keydown", (event) => {
if (list.hidden) return;
if (event.key === "ArrowDown") {
event.preventDefault();
highlight((active + 1) % suggestions.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
highlight((active - 1 + suggestions.length) % suggestions.length);
} else if (event.key === "Enter" && active >= 0) {
event.preventDefault();
choose(active);
} else if (event.key === "Escape") {
show([]);
}
});
input.addEventListener("blur", () => show([]));
Reload the page and type "1005 W Burnside". "1005 West Burnside Street, Portland, OR, USA" appears below the field. Press the down arrow and Enter, and the form fills in with Portland, Oregon, 97209, and United States.
What each part does:
- Waiting for a pause. Each keystroke restarts a 200 millisecond timer, so the API is asked once someone stops typing, not for every letter. Fewer than 3 characters asks nothing.
- Dropping stale answers. Each request cancels the one before it with an
AbortController, so a slow answer can't replace a newer one. - Duplicates. Results from different sources can share a label, so the list keeps the first of each.
- The keyboard. The up and down arrows move through the list, Enter picks a suggestion, and Escape closes the list.
aria-activedescendanttells screen readers which suggestion is highlighted while focus stays in the field. - The mouse. Suggestions respond to
mousedownand cancel its default, so the field keeps focus and the list doesn't close before the click lands. - Filling the form. The chosen suggestion's
addresshas its parts:houseNumber,street,locality,region,postalCode, andcountry. A place that isn't an address, such as a city, has only some of them, so the field falls back to the full label.
The whole page
Here's index.html with all of the above:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Address form</title>
<style>
form {
display: grid;
gap: 0.25rem;
max-width: 24rem;
font: 16px system-ui, sans-serif;
}
input {
width: 100%;
box-sizing: border-box;
padding: 0.5rem;
margin-bottom: 0.75rem;
}
.autocomplete {
position: relative;
}
#suggestions {
position: absolute;
top: 2.5rem;
left: 0;
right: 0;
z-index: 1;
margin: 0;
padding: 0;
list-style: none;
background: white;
border: 1px solid #ccc;
}
#suggestions li {
padding: 0.5rem;
cursor: pointer;
}
#suggestions li:hover,
#suggestions li[aria-selected="true"] {
background: #f1f3f5;
}
</style>
</head>
<body>
<form>
<label for="address">Address</label>
<div class="autocomplete">
<input id="address" name="address" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="suggestions" autocomplete="off">
<ul id="suggestions" role="listbox" aria-label="Suggested addresses" hidden></ul>
</div>
<label for="city">City</label>
<input id="city" name="city" autocomplete="address-level2">
<label for="region">State or region</label>
<input id="region" name="region" autocomplete="address-level1">
<label for="postalCode">Postal code</label>
<input id="postalCode" name="postalCode" autocomplete="postal-code">
<label for="country">Country</label>
<input id="country" name="country" autocomplete="country-name">
</form>
<script type="module">
const key = "mapricot_pk_…";
const form = document.querySelector("form");
const input = document.querySelector("#address");
const list = document.querySelector("#suggestions");
let suggestions = [];
let active = -1;
let controller;
let timer;
async function suggest() {
controller?.abort();
const q = input.value.trim();
if (q.length < 3) return show([]);
controller = new AbortController();
try {
const response = await fetch(`https://api.mapricot.com/v1/autocomplete?${new URLSearchParams({ q, limit: 5 })}`, {
headers: { Authorization: `Bearer ${key}` },
signal: controller.signal,
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
const { results } = await response.json();
show(results.filter((result, index) => results.findIndex((other) => other.label === result.label) === index));
} catch (error) {
if (error.name !== "AbortError") show([]);
}
}
function show(items) {
suggestions = items;
active = -1;
list.replaceChildren(
...items.map((result, index) => {
const option = document.createElement("li");
option.id = `suggestion-${index}`;
option.setAttribute("role", "option");
option.setAttribute("aria-selected", "false");
option.textContent = result.label;
option.addEventListener("mousedown", (event) => {
event.preventDefault();
choose(index);
});
return option;
}),
);
list.hidden = !items.length;
input.setAttribute("aria-expanded", String(items.length > 0));
input.removeAttribute("aria-activedescendant");
}
function highlight(index) {
list.children[active]?.setAttribute("aria-selected", "false");
active = index;
list.children[active].setAttribute("aria-selected", "true");
input.setAttribute("aria-activedescendant", list.children[active].id);
}
function choose(index) {
const { label, address = {} } = suggestions[index];
input.value = [address.houseNumber, address.street].filter(Boolean).join(" ") || label;
form.elements.city.value = address.locality ?? "";
form.elements.region.value = address.region ?? "";
form.elements.postalCode.value = address.postalCode ?? "";
form.elements.country.value = address.country ?? "";
show([]);
}
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(suggest, 200);
});
input.addEventListener("keydown", (event) => {
if (list.hidden) return;
if (event.key === "ArrowDown") {
event.preventDefault();
highlight((active + 1) % suggestions.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
highlight((active - 1 + suggestions.length) % suggestions.length);
} else if (event.key === "Enter" && active >= 0) {
event.preventDefault();
choose(active);
} else if (event.key === "Escape") {
show([]);
}
});
input.addEventListener("blur", () => show([]));
</script>
</body>
</html>
Better suggestions
Add these to the URLSearchParams in suggest:
focus, aslat,lon, ranks suggestions near that point higher, such as the store's location, or the visitor's from IP geolocation.country, such asUSorUS,CA, keeps suggestions to those countries.lang, such asfr, sets the language of names and labels.
What it costs
Each request is billed, so the wait for a pause matters: typing a full address usually takes a few requests, not one for every letter. The first 250,000 requests every 30 days are free, then they cost $0.10 per 1,000. There's no separate session price. See pricing.
Questions
Is there a free address autocomplete API? Mapricot gives every account 250,000 free requests every 30 days, with no credit card, and you can use them in production. Getting started takes a few minutes.
How does this compare with Google Places Autocomplete? Google bills autocomplete by session, and then the place details for the address cost extra. Google Places API pricing walks through it. Mapricot's suggestions include the address parts, so there's no second request.
Why not use a <datalist>? A datalist filters its options by the text typed, can't show each suggestion's full label consistently across browsers, and gives no event for picking one that the code can use to fill the other fields.