Created
March 10, 2026 18:02
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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
Usage
Dependencies
How It Works
navigator.languageand pre-filled