Skip to content

Instantly share code, notes, and snippets.

@ericboehs
Created March 10, 2026 18:02
Show Gist options
  • Select an option

  • Save ericboehs/a93f4f172b0f9e488976c5a6983f2329 to your computer and use it in GitHub Desktop.

Select an option

Save ericboehs/a93f4f172b0f9e488976c5a6983f2329 to your computer and use it in GitHub Desktop.
Zip-first address form — #nobuild single HTML file with auto-fill city/state from postal code, 60+ countries, searchable country picker. Preview: https://gistpreview.github.io/?a93f4f172b0f9e488976c5a6983f2329
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Address Form — Zip First</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
:root {
--pico-font-size: 100%;
}
body {
padding: 2rem 1rem;
}
main {
max-width: 540px;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
h1 + p {
color: var(--pico-muted-color);
margin-bottom: 2rem;
}
.row {
display: grid;
gap: 0 1rem;
}
.row-3 { grid-template-columns: 1fr 1.5fr 1fr; }
label {
font-size: 0.875rem;
font-weight: 600;
}
/* Country combobox */
.combobox {
position: relative;
margin-bottom: var(--pico-spacing);
}
.combobox input {
margin-bottom: 0;
}
.combobox-list {
display: none;
position: absolute;
z-index: 10;
top: 100%;
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
background: var(--pico-card-background-color, #fff);
border: 1px solid var(--pico-form-element-border-color);
border-top: none;
border-radius: 0 0 var(--pico-border-radius) var(--pico-border-radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.combobox.open .combobox-list {
display: block;
}
.combobox-list li {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
}
.combobox-list li:hover,
.combobox-list li.active {
background: var(--pico-primary-focus);
}
.combobox-list li.hidden {
display: none;
}
/* Zip spinner */
.zip-wrapper {
position: relative;
}
.zip-spinner {
display: none;
position: absolute;
right: 0.75rem;
top: 50%;
width: 1rem;
height: 1rem;
border: 2px solid var(--pico-muted-color);
border-top-color: var(--pico-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.zip-wrapper.loading .zip-spinner {
display: block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
input.autofilled {
animation: flash 0.6s ease;
}
@keyframes flash {
0% { background-color: color-mix(in srgb, var(--pico-primary) 20%, transparent); }
100% { background-color: transparent; }
}
button[type="submit"] {
margin-top: 0.5rem;
}
</style>
</head>
<body>
<main class="container">
<h1>Shipping Address</h1>
<p>Enter your postal code to auto-fill city and state.</p>
<form id="address-form" autocomplete="on">
<label for="country">Country</label>
<div class="combobox" id="country-combobox">
<input type="text" id="country" name="country" autocomplete="off" placeholder="Start typing a country..." required>
<ul class="combobox-list" id="country-list"></ul>
</div>
<label for="address1">Address</label>
<input type="text" id="address1" name="address1" autocomplete="address-line1" placeholder="123 Main St" required>
<input type="text" id="address2" name="address2" autocomplete="address-line2" placeholder="Apt, suite, unit (optional)">
<div class="row row-3">
<div>
<label for="zip" id="zip-label">Zip Code</label>
<div class="zip-wrapper" id="zip-wrapper">
<input type="text" id="zip" name="zip" autocomplete="postal-code" placeholder="90210" inputmode="numeric" maxlength="10" required>
<span class="zip-spinner"></span>
</div>
</div>
<div>
<label for="city">City</label>
<input type="text" id="city" name="city" autocomplete="address-level2" placeholder="Beverly Hills" required>
</div>
<div>
<label for="state" id="state-label">State</label>
<input type="text" id="state" name="state" autocomplete="address-level1" placeholder="CA" required>
</div>
</div>
<button type="submit">Save Address</button>
</form>
</main>
<script>
const zip = document.getElementById("zip");
const city = document.getElementById("city");
const state = document.getElementById("state");
const country = document.getElementById("country");
const combobox = document.getElementById("country-combobox");
const list = document.getElementById("country-list");
const wrapper = document.getElementById("zip-wrapper");
const zipLabel = document.getElementById("zip-label");
const stateLabel = document.getElementById("state-label");
// All zippopotam.us supported countries
const countries = [
{ code: "ad", name: "Andorra" },
{ code: "ar", name: "Argentina" },
{ code: "as", name: "American Samoa" },
{ code: "at", name: "Austria" },
{ code: "au", name: "Australia" },
{ code: "bd", name: "Bangladesh" },
{ code: "be", name: "Belgium" },
{ code: "bg", name: "Bulgaria" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "ch", name: "Switzerland" },
{ code: "cz", name: "Czech Republic" },
{ code: "de", name: "Germany" },
{ code: "dk", name: "Denmark" },
{ code: "do", name: "Dominican Republic" },
{ code: "es", name: "Spain" },
{ code: "fi", name: "Finland" },
{ code: "fo", name: "Faroe Islands" },
{ code: "fr", name: "France" },
{ code: "gb", name: "United Kingdom" },
{ code: "gf", name: "French Guyana" },
{ code: "gg", name: "Guernsey" },
{ code: "gl", name: "Greenland" },
{ code: "gp", name: "Guadeloupe" },
{ code: "gt", name: "Guatemala" },
{ code: "gu", name: "Guam" },
{ code: "gy", name: "Guyana" },
{ code: "hr", name: "Croatia" },
{ code: "hu", name: "Hungary" },
{ code: "im", name: "Isle of Man" },
{ code: "in", name: "India" },
{ code: "is", name: "Iceland" },
{ code: "it", name: "Italy" },
{ code: "je", name: "Jersey" },
{ code: "jp", name: "Japan" },
{ code: "li", name: "Liechtenstein" },
{ code: "lk", name: "Sri Lanka" },
{ code: "lt", name: "Lithuania" },
{ code: "lu", name: "Luxembourg" },
{ code: "mc", name: "Monaco" },
{ code: "md", name: "Moldova" },
{ code: "mh", name: "Marshall Islands" },
{ code: "mk", name: "North Macedonia" },
{ code: "mp", name: "Northern Mariana Islands" },
{ code: "mq", name: "Martinique" },
{ code: "mx", name: "Mexico" },
{ code: "my", name: "Malaysia" },
{ code: "nl", name: "Netherlands" },
{ code: "no", name: "Norway" },
{ code: "nz", name: "New Zealand" },
{ code: "ph", name: "Philippines" },
{ code: "pk", name: "Pakistan" },
{ code: "pl", name: "Poland" },
{ code: "pm", name: "Saint Pierre and Miquelon" },
{ code: "pr", name: "Puerto Rico" },
{ code: "pt", name: "Portugal" },
{ code: "re", name: "Réunion" },
{ code: "ru", name: "Russia" },
{ code: "se", name: "Sweden" },
{ code: "si", name: "Slovenia" },
{ code: "sj", name: "Svalbard and Jan Mayen" },
{ code: "sk", name: "Slovakia" },
{ code: "sm", name: "San Marino" },
{ code: "th", name: "Thailand" },
{ code: "tr", name: "Turkey" },
{ code: "us", name: "United States" },
{ code: "va", name: "Vatican City" },
{ code: "vi", name: "U.S. Virgin Islands" },
{ code: "yt", name: "Mayotte" },
{ code: "za", name: "South Africa" },
];
// Build lookups
const nameToCode = {};
const codeToName = {};
countries.forEach(c => {
nameToCode[c.name.toLowerCase()] = c.code;
codeToName[c.code] = c.name;
});
// Populate dropdown
countries.forEach(c => {
const li = document.createElement("li");
li.textContent = c.name;
li.dataset.code = c.code;
li.addEventListener("mousedown", (e) => {
e.preventDefault(); // prevent blur
selectCountry(c.name);
});
list.appendChild(li);
});
function getCountryCode() {
return nameToCode[country.value.toLowerCase()] || null;
}
// Combobox behavior
let activeIndex = -1;
country.addEventListener("focus", () => {
filterList();
combobox.classList.add("open");
});
country.addEventListener("blur", () => {
combobox.classList.remove("open");
activeIndex = -1;
});
country.addEventListener("input", () => {
filterList();
combobox.classList.add("open");
activeIndex = 0;
highlightItem([...list.children].filter(li => !li.classList.contains("hidden")));
updateLabels();
});
country.addEventListener("keydown", (e) => {
const visible = [...list.children].filter(li => !li.classList.contains("hidden"));
if (e.key === "ArrowDown") {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, visible.length - 1);
highlightItem(visible);
} else if (e.key === "ArrowUp") {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
highlightItem(visible);
} else if (e.key === "Enter" && visible.length > 0) {
e.preventDefault();
const idx = activeIndex >= 0 ? activeIndex : 0;
selectCountry(visible[idx].textContent);
} else if (e.key === "Escape") {
combobox.classList.remove("open");
}
});
function filterList() {
const query = country.value.toLowerCase();
for (const li of list.children) {
const match = li.textContent.toLowerCase().includes(query);
li.classList.toggle("hidden", !match);
}
}
function highlightItem(visible) {
for (const li of list.children) li.classList.remove("active");
if (visible[activeIndex]) {
visible[activeIndex].classList.add("active");
visible[activeIndex].scrollIntoView({ block: "nearest" });
}
}
function selectCountry(name) {
country.value = name;
combobox.classList.remove("open");
activeIndex = -1;
updateLabels();
}
// Country-specific config
const defaults = { min: 4, zip: "Postal Code", state: "Region", placeholder: "" };
const countryConfig = {
us: { min: 5, zip: "Zip Code", state: "State", placeholder: "90210" },
ca: { min: 3, zip: "Postal Code", state: "Province", placeholder: "K1A" },
gb: { min: 2, zip: "Postcode", state: "County", placeholder: "SW1A 1AA" },
de: { min: 5, zip: "Postleitzahl", state: "State", placeholder: "10115" },
fr: { min: 5, zip: "Code Postal", state: "Region", placeholder: "75001" },
au: { min: 4, zip: "Postcode", state: "State", placeholder: "2000" },
mx: { min: 5, zip: "Código Postal", state: "State", placeholder: "06600" },
jp: { min: 3, zip: "Postal Code", state: "Prefecture", placeholder: "100-0001" },
in: { min: 6, zip: "PIN Code", state: "State", placeholder: "110001" },
br: { min: 5, zip: "CEP", state: "State", placeholder: "01001" },
it: { min: 5, zip: "CAP", state: "Province", placeholder: "00100" },
es: { min: 5, zip: "Código Postal", state: "Province", placeholder: "28001" },
nl: { min: 4, zip: "Postcode", state: "Province", placeholder: "1012" },
pl: { min: 5, zip: "Kod Pocztowy", state: "Voivodeship",placeholder: "00-001" },
ch: { min: 4, zip: "Postleitzahl", state: "Canton", placeholder: "8001" },
at: { min: 4, zip: "Postleitzahl", state: "State", placeholder: "1010" },
se: { min: 5, zip: "Postnummer", state: "County", placeholder: "11120" },
no: { min: 4, zip: "Postnummer", state: "County", placeholder: "0001" },
dk: { min: 4, zip: "Postnummer", state: "Region", placeholder: "1000" },
nz: { min: 4, zip: "Postcode", state: "Region", placeholder: "6011" },
za: { min: 4, zip: "Postal Code", state: "Province", placeholder: "2000" },
};
// Auto-detect country from browser locale
const detectedCode = (navigator.language || "").split("-").pop().toLowerCase();
if (codeToName[detectedCode]) {
country.value = codeToName[detectedCode];
} else {
country.value = "United States";
}
updateLabels();
function updateLabels() {
const cc = getCountryCode();
const cfg = (cc && countryConfig[cc]) || defaults;
zipLabel.textContent = cfg.zip;
stateLabel.textContent = cfg.state;
zip.placeholder = cfg.placeholder;
}
// Zip lookup
zip.addEventListener("input", () => {
const code = zip.value.trim();
const cc = getCountryCode();
if (!cc) return;
const min = (countryConfig[cc] || defaults).min;
if (code.length >= min) lookup(cc, code);
});
async function lookup(cc, code) {
wrapper.classList.add("loading");
try {
const res = await fetch(`https://api.zippopotam.us/${cc}/${code}`);
if (!res.ok) return;
const data = await res.json();
const place = data.places?.[0];
if (!place) return;
fill(city, place["place name"]);
fill(state, place["state abbreviation"] || place["state"]);
} catch {
// API down or bad zip — leave fields alone
} finally {
wrapper.classList.remove("loading");
}
}
function fill(input, value) {
if (!value) return;
input.value = value;
input.classList.remove("autofilled");
void input.offsetWidth;
input.classList.add("autofilled");
}
document.getElementById("address-form").addEventListener("submit", (e) => {
e.preventDefault();
alert("Address saved! (This is a demo)");
});
</script>
</body>
</html>
@ericboehs
Copy link
Author

Zip-First Address Form

A #nobuild single HTML file that puts the zip/postal code first and auto-fills city, state, and country. Inspired by this HN discussion.

Features

  • Zip-first UX — type your postal code, city & state auto-populate instantly
  • 60+ countries supported via zippopotam.us API
  • Searchable country picker — custom combobox with keyboard navigation (arrow keys + Enter)
  • Localized labels — "Zip Code" (US), "Postcode" (UK/AU), "Postleitzahl" (DE/CH), "Code Postal" (FR), "PIN Code" (IN), etc.
  • Auto-detect country from browser locale
  • Beautiful styling via Pico CSS — zero custom classes needed
  • All fields stay editable — auto-fill is a suggestion, not a lock
  • Graceful degradation — if API is down or zip is unknown, fields just stay empty
  • Zero dependencies — no npm, no build step, no framework

Usage

# Download
curl -sL https://gist.githubusercontent.com/ericboehs/a93f4f172b0f9e488976c5a6983f2329/raw/index.html -o index.html

# Open
open index.html

Dependencies

How It Works

  1. Country is auto-detected from navigator.language and pre-filled
  2. User types address, then enters postal code
  3. On reaching the minimum digit count for that country, a fetch fires to zippopotam.us
  4. City and state fields populate with a subtle highlight animation
  5. User can override any auto-filled value

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment