Commerce
Address form
A postal address group whose labels, order and required fields follow the chosen country, ready for autofill.
- Built on
<fieldset><legend>autocomplete tokens<select>patternContainer queries- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.6 kB
Code
import { AddressForm } from "@/components/equip/address-form";
export default function Demo() {
return (
<AddressForm
legend="Delivery address"
section="shipping"
defaultCountry="DE"
fields={["country", "postal", "city"]}
size="s"
style={{ width: 224 }}
/>
);
}Examples
Shipping address in a form
Every field carries a shipping autocomplete token, so a browser fills the whole group from one saved address.
Code
import { AddressForm } from "@/components/equip/address-form";
import { Button } from "@/components/equip/button";
export default function Example() {
return (
<form action="" style={{ display: "grid", gap: 16, width: "100%", maxWidth: 520 }}>
<AddressForm legend="Shipping address" section="shipping" namePrefix="shipping-" defaultCountry="US" fields={["country", "name", "line1", "line2", "city", "region", "postal", "tel"]} />
<div>
<Button type="submit" variant="accent">
Continue to payment
</Button>
</div>
</form>
);
}Switching country
Germany puts the postal code before the city and has no region; typed values stay put as fields move.
Layout for DE.
Code
"use client";
import { useState } from "react";
import { AddressForm, type AddressCountry } from "@/components/equip/address-form";
export default function Example() {
const [country, setCountry] = useState<AddressCountry>("DE");
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 520 }}>
<AddressForm
legend="Delivery address"
section="shipping"
country={country}
onCountryChange={setCountry}
countries={["DE", "FR", "GB", "US"]}
defaultValues={{ name: "Lena Hoffmann", line1: "Friedrichstraße 112", postal: "10117", city: "Berlin" }}
/>
<p style={{ margin: 0, fontSize: 13, color: "var(--eq-ink-2)" }}>Layout for {country}.</p>
</div>
);
}Japanese address
Postal code first, then prefecture and city, the order a Japanese address is written and looked up.
Code
import { AddressForm } from "@/components/equip/address-form";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 520 }}>
<AddressForm
legend="Shipping address"
section="shipping"
defaultCountry="JP"
countries={["JP"]}
defaultValues={{ name: "Sato Haruka", postal: "150-0002", region: "Tokyo", city: "Shibuya-ku", line1: "2-21-1 Shibuya" }}
/>
</div>
);
}Billing address with errors
errors takes text by field key or full name, as a server returns it; each message is tied to its field.
Code
import { AddressForm } from "@/components/equip/address-form";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 520 }}>
<AddressForm
legend="Billing address"
section="billing"
namePrefix="billing-"
defaultCountry="GB"
fields={["country", "name", "organization", "line1", "line2", "city", "region", "postal"]}
defaultValues={{ name: "Priya Shah", organization: "Harbour Studio Ltd", line1: "14 Bermondsey Street", city: "London", postal: "SE1 2E" }}
errors={{ "billing-postal": "Enter a full UK postcode, such as SE1 2ES." }}
/>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
legend | ReactNode | none | The group's visible name, such as Shipping address. |
section | "shipping" | "billing" | none | Prefixes every autocomplete token, as in shipping postal-code, so two addresses on a page stay apart. |
namePrefix | string | "" | Prefixes every field name, such as shipping-, so the group can sit beside another in one form. |
country | "US" | "GB" | "CA" | "AU" | "DE" | "FR" | "IN" | "JP" | none | Controlled country. Pair it with onCountryChange. |
defaultCountry | AddressCountry | none | Uncontrolled starting country. Defaults to the first of countries. |
onCountryChange | (country: AddressCountry) => void | none | Called when the country select changes. |
countries | AddressCountry[] | all eight | Limits the country select to these, in this order. |
fields | AddressFieldKey[] | ["country", "name", "line1", "line2", "city", "region", "postal"] | Which fields to show; add organization and tel to ask for them. They always appear in the country's own order. |
defaultValues | Partial<Record<AddressFieldKey, string>> | none | Starting values by field key. |
errors | Record<string, string> | none | Error text by field key, such as postal, or by full name, such as shipping-postal. |
labels | Partial<Record<AddressFieldKey, string>> | none | Replaces a field's label for every country, for another language. |
size | "s" | "m" | "l" | "m" | Passed to every field and the select. |
...rest | ComponentProps<"fieldset"> | none | Every native fieldset attribute, including disabled, which disables every field. |
Anatomy
| root | .eq-address-form | The native fieldset. |
|---|---|---|
| legend | .eq-address-form-legend | |
| body | .eq-address-form-body | A 12-column grid and the container the narrow layout queries. |
| cell | .eq-address-form-cell | Wraps one Field or Select; data-field names it and --_span sets its share of the row. |
Notes
- The country select comes first because it decides the labels, order and postal format of everything after it.
- Fields are keyed by what they hold, so a value typed before a country change stays in its field as the layout moves.
- Postal code patterns are set only where the format is fixed (US, DE, FR, IN, JP, AU); UK and Canadian postcodes are left to the server so a valid code is never blocked.
- Country options submit ISO 3166 codes such as DE, which browsers also match when they autofill.
- Only eight countries are described; add another by giving it rows and labels in the part's COUNTRIES table.
- Region is a text field everywhere, so autofill can write a state or prefecture name in any form the browser has saved.
- Inside the Form part, errors from its render function can be passed straight to errors because they are keyed by field name.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/address-form.tsx252 lines
"use client";
import { useId, useState } from "react";
import type { ChangeEvent, ComponentProps, CSSProperties, HTMLAttributes, ReactNode } from "react";
import { Field } from "./field";
import { Select } from "./select";
import "./address-form.css";
/** The pieces of a postal address. Each one submits as namePrefix + key. */
export type AddressFieldKey = "country" | "name" | "organization" | "line1" | "line2" | "city" | "region" | "postal" | "tel";
export type AddressCountry = "US" | "GB" | "CA" | "AU" | "DE" | "FR" | "IN" | "JP";
type Spec = {
/** English name for the country select. */
name: string;
/** Rows of fields in the order the country writes an address. Two or three keys share a row. */
rows: AddressFieldKey[][];
/** Labels that differ from the base ones. */
labels: Partial<Record<AddressFieldKey, string>>;
/** Fields that must be filled, on top of country, name, line1, city and postal. */
regionRequired: boolean;
postalPattern?: string;
postalInputMode: HTMLAttributes<HTMLInputElement>["inputMode"];
};
/* Labels follow each country's own forms in English; patterns only where the format is fixed. */
const COUNTRIES: Record<AddressCountry, Spec> = {
US: {
name: "United States",
rows: [["country"], ["name"], ["organization"], ["line1"], ["line2"], ["city", "region", "postal"], ["tel"]],
labels: { line1: "Street address", line2: "Apartment, suite, unit (optional)", city: "City", region: "State", postal: "ZIP code" },
regionRequired: true,
postalPattern: "\\d{5}(-\\d{4})?",
postalInputMode: "numeric",
},
GB: {
name: "United Kingdom",
rows: [["country"], ["name"], ["organization"], ["line1"], ["line2"], ["city", "postal"], ["region"], ["tel"]],
labels: { line1: "Address line 1", line2: "Address line 2 (optional)", city: "Town or city", region: "County (optional)", postal: "Postcode" },
regionRequired: false,
postalInputMode: "text",
},
CA: {
name: "Canada",
rows: [["country"], ["name"], ["organization"], ["line1"], ["line2"], ["city", "region", "postal"], ["tel"]],
labels: { line1: "Street address", line2: "Apartment, suite, unit (optional)", city: "City", region: "Province", postal: "Postal code" },
regionRequired: true,
postalInputMode: "text",
},
AU: {
name: "Australia",
rows: [["country"], ["name"], ["organization"], ["line1"], ["line2"], ["city", "region", "postal"], ["tel"]],
labels: { line1: "Street address", line2: "Apartment, unit, level (optional)", city: "Suburb", region: "State", postal: "Postcode" },
regionRequired: true,
postalPattern: "\\d{4}",
postalInputMode: "numeric",
},
DE: {
name: "Germany",
rows: [["country"], ["name"], ["organization"], ["line1"], ["line2"], ["postal", "city"], ["tel"]],
labels: { line1: "Street and house number", line2: "Additional address details (optional)", city: "City", postal: "Postal code" },
regionRequired: false,
postalPattern: "\\d{5}",
postalInputMode: "numeric",
},
FR: {
name: "France",
rows: [["country"], ["name"], ["organization"], ["line1"], ["line2"], ["postal", "city"], ["tel"]],
labels: { line1: "Street address", line2: "Building, floor, apartment (optional)", city: "City", postal: "Postal code" },
regionRequired: false,
postalPattern: "\\d{5}",
postalInputMode: "numeric",
},
IN: {
name: "India",
rows: [["country"], ["name"], ["organization"], ["line1"], ["line2"], ["city", "postal"], ["region"], ["tel"]],
labels: { line1: "Flat, house number, building", line2: "Area, street, village (optional)", city: "Town or city", region: "State", postal: "PIN code" },
regionRequired: true,
postalPattern: "\\d{6}",
postalInputMode: "numeric",
},
JP: {
name: "Japan",
rows: [["country"], ["name"], ["organization"], ["postal"], ["region", "city"], ["line1"], ["line2"], ["tel"]],
labels: { line1: "Address", line2: "Building and room (optional)", city: "City, ward, town", region: "Prefecture", postal: "Postal code" },
regionRequired: true,
postalPattern: "\\d{3}-?\\d{4}",
postalInputMode: "numeric",
},
};
const ORDER = Object.keys(COUNTRIES) as AddressCountry[];
const BASE_LABELS: Record<AddressFieldKey, string> = {
country: "Country or region",
name: "Full name",
organization: "Company (optional)",
line1: "Street address",
line2: "Address line 2 (optional)",
city: "City",
region: "Region",
postal: "Postal code",
tel: "Phone (optional)",
};
/** The autofill token for each key, before any shipping or billing prefix. */
const TOKENS: Record<AddressFieldKey, string> = {
country: "country",
name: "name",
organization: "organization",
line1: "address-line1",
line2: "address-line2",
city: "address-level2",
region: "address-level1",
postal: "postal-code",
tel: "tel",
};
/**
* Columns a field spans on a 12-column grid, wide and narrow. A postcode takes less room than
* a town; three fields to a row share it evenly, then the first takes a row of its own.
*/
function spanOf(row: AddressFieldKey[], index: number): CSSProperties {
let wide = 12;
let narrow = 12;
if (row.length === 3) {
wide = 4;
narrow = index === 0 ? 12 : 6;
} else if (row.length === 2) {
wide = row[index] === "postal" ? 5 : row.includes("postal") ? 7 : 6;
narrow = wide;
}
return { "--_span": wide, "--_narrow": narrow } as CSSProperties;
}
const DEFAULT_FIELDS: AddressFieldKey[] = ["country", "name", "line1", "line2", "city", "region", "postal"];
export type AddressFormProps = Omit<ComponentProps<"fieldset">, "defaultValue" | "onChange"> & {
/** The group's visible name, such as "Shipping address". */
legend: ReactNode;
/** Prefixes every autocomplete token, so a browser keeps the two addresses on a page apart. */
section?: "shipping" | "billing";
/** Prefixes every field name, such as "shipping-", so two address groups can share a form. */
namePrefix?: string;
/** Controlled country. Pair it with onCountryChange. */
country?: AddressCountry;
/** Uncontrolled starting country. */
defaultCountry?: AddressCountry;
onCountryChange?: (country: AddressCountry) => void;
/** Limits the country select to these, in this order. */
countries?: AddressCountry[];
/** Which fields to show. They always appear in the country's own order. */
fields?: AddressFieldKey[];
/** Starting values by field key. */
defaultValues?: Partial<Record<AddressFieldKey, string>>;
/** Error text by field key or by full field name. */
errors?: Record<string, string>;
/** Replaces the label of a field for every country, for another language or a house term. */
labels?: Partial<Record<AddressFieldKey, string>>;
size?: "s" | "m" | "l";
};
export function AddressForm({
legend,
section,
namePrefix = "",
country: controlled,
defaultCountry,
onCountryChange,
countries = ORDER,
fields = DEFAULT_FIELDS,
defaultValues,
errors,
labels,
size = "m",
className,
...rest
}: AddressFormProps) {
const id = useId();
const list = countries.length > 0 ? countries : ORDER;
const [internal, setInternal] = useState<AddressCountry>(() => {
const start = defaultCountry ?? (defaultValues?.country as AddressCountry | undefined);
return start && list.includes(start) ? start : list[0];
});
const country = controlled ?? internal;
const spec = COUNTRIES[country] ?? COUNTRIES[list[0]];
const shown = new Set(fields);
const rows = spec.rows.map((row) => row.filter((key) => shown.has(key))).filter((row) => row.length > 0);
function changeCountry(event: ChangeEvent<HTMLSelectElement>) {
const next = event.currentTarget.value as AddressCountry;
if (controlled === undefined) setInternal(next);
onCountryChange?.(next);
}
function render(key: AddressFieldKey) {
const name = `${namePrefix}${key}`;
const fieldId = `${id}-${key}`;
const label = labels?.[key] ?? spec.labels[key] ?? BASE_LABELS[key];
const autoComplete = [section, TOKENS[key]].filter(Boolean).join(" ");
const error = errors?.[key] ?? errors?.[name];
if (key === "country") {
return (
<Select id={fieldId} name={name} label={label} size={size} error={error} autoComplete={autoComplete} required value={country} onChange={changeCountry}>
{list.map((code) => (
<option key={code} value={code}>
{COUNTRIES[code].name}
</option>
))}
</Select>
);
}
const required = key === "name" || key === "line1" || key === "city" || key === "postal" || (key === "region" && spec.regionRequired);
const postal = key === "postal";
return (
<Field
id={fieldId}
name={name}
label={label}
size={size}
error={error}
autoComplete={autoComplete}
defaultValue={defaultValues?.[key]}
required={required}
type={key === "tel" ? "tel" : "text"}
inputMode={postal ? spec.postalInputMode : undefined}
pattern={postal ? spec.postalPattern : undefined}
autoCapitalize={postal && spec.postalInputMode === "text" ? "characters" : undefined}
spellCheck={false}
/>
);
}
return (
<fieldset {...rest} className={["eq-address-form", className].filter(Boolean).join(" ")} data-size={size} data-country={country}>
<legend className="eq-address-form-legend">{legend}</legend>
<div className="eq-address-form-body">
{/* One flat grid keyed by field, so a field keeps its value when a new country moves it. */}
{rows.flatMap((row) =>
row.map((key, index) => (
<div key={key} className="eq-address-form-cell" data-field={key} style={spanOf(row, index)}>
{render(key)}
</div>
)),
)}
</div>
</fieldset>
);
}components/equip/address-form.css56 lines
@layer equip {
/* A fieldset sizes to its widest content by default; min-width: 0 lets it shrink with its column. */
.eq-address-form {
box-sizing: border-box;
inline-size: 100%;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
color: var(--eq-ink);
font-family: var(--eq-font);
}
.eq-address-form-legend {
margin: 0 0 var(--eq-space-3);
padding: 0;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-m) / 1.3 var(--eq-font);
}
.eq-address-form[data-size="s"] .eq-address-form-legend {
margin-bottom: var(--eq-space-2);
font-size: var(--eq-text-s);
}
/* One flat 12-column grid. Each cell spans its share of a row, set inline as --_span. */
.eq-address-form-body {
container-type: inline-size;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: var(--eq-space-3);
min-width: 0;
}
.eq-address-form[data-size="s"] .eq-address-form-body {
gap: var(--eq-space-2);
}
.eq-address-form-cell {
grid-column: span var(--_span, 12);
min-width: 0;
}
/* Three to a row gets cramped first: the town takes its own row, region and postcode share the next. */
@container (width < 22rem) {
.eq-address-form-cell {
grid-column: span var(--_narrow, 12);
}
}
@container (width < 12rem) {
.eq-address-form-cell {
grid-column: 1 / -1;
}
}
}