Address form
Addresses that fit the country. Fields, order, labels and the postcode check follow the country, delivery and billing stay separate when needed, saved addresses are cards, and a search fills everything in.
One of 3 components in Address, payment and consent
Variants
Details
What Address form brings along, read from the registry and the source.
- Category
- Address, payment and consent
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/address-form.tsx
- npm packages
- None
- Languages
- English and German, 40 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AddressFormProps interface from components/ui/address-form.tsx, with the comments from the code.
variantoptionalAddressFormVariantinternationalshipping-billingaddress-booklookup
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodecountriesoptionalstring[]Country codes to offer, default DE, AT, CH, NL, FR, GB, US
defaultCountryoptionalstringsavedoptionalSavedAddress[]address-book: saved addresses
suggestionsoptionalAddress[]lookup: addresses the search can find (your address service fills this)
submitLabeloptionalstringonSubmitoptional(data: { address: Address; billing?: Address; business?: { company: string; vat: string } }) => void
Built-in texts
Address form writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 40 texts
| Key | English | German |
|---|---|---|
| country | English Country | German Land |
| name | English Full name | German Vor- und Nachname |
| company | English Company | German Firma |
| street | English Street and number | German Straße und Hausnummer |
| line1 | English Address | German Adresse |
| line2 | English Apartment, suite, floor | German Wohnung, Etage, Zusatz |
| postcode | English Postcode | German Postleitzahl |
| zip | English ZIP code | German ZIP-Code |
| city | English City | German Ort |
| state | English State | German Bundesstaat |
| choose | English Please choose | German Bitte wählen |
| nameNeeded | English Please add a name, it goes on the parcel. | German Bitte gib einen Namen an, er steht auf dem Paket. |
| streetNeeded | English Please add the street and number, so the courier finds the door. | German Bitte gib Straße und Hausnummer an, damit der Bote die Tür findet. |
| cityNeeded | English Please add the city. | German Bitte gib den Ort an. |
| companyNeeded | English Please add the company, the invoice is made out to it. | German Bitte gib die Firma an, auf sie lautet die Rechnung. |
| stateNeeded | English Please choose the state, the address is incomplete without it. | German Bitte wähle den Bundesstaat, ohne ihn ist die Adresse unvollständig. |
| postcodeNeeded | English Please add the postcode. | German Bitte gib die Postleitzahl an. |
| postcodeFormat | English This postcode looks different from the usual {c} form, for example {e}. Please check it so nothing gets lost. | German Diese Postleitzahl sieht anders aus als die übliche Form für {c}, zum Beispiel {e}. Bitte prüf sie, damit nichts verloren geht. |
| save | English Use this address | German Diese Adresse verwenden |
| saved | English Address saved | German Adresse gespeichert |
| shipping | English Delivery address | German Lieferadresse |
| billing | English Billing address | German Rechnungsadresse |
| same | English Billing address is the same | German Rechnungsadresse ist dieselbe |
| business | English I order as a business | German Ich bestelle als Firma |
| vat | English VAT number | German USt-IdNr. |
| vatHint | English For an invoice without VAT inside the EU | German Für eine Rechnung ohne Umsatzsteuer innerhalb der EU |
| continue | English Continue to payment | German Weiter zur Zahlung |
| default | English Default | German Standard |
| makeDefault | English Make default | German Als Standard |
| edit | English Edit {l} | German {l} bearbeiten |
| remove | English Remove {l} | German {l} entfernen |
| removeShort | English Remove | German Entfernen |
| add | English Add a new address | German Neue Adresse hinzufügen |
| cancel | English Cancel | German Abbrechen |
| deliverHere | English Deliver here | German Hierhin liefern |
| search | English Search for your address | German Adresse suchen |
| searchHint | English Street and number are enough | German Straße und Hausnummer reichen |
| noMatch | English No address found. Check the spelling or enter it by hand. | German Keine Adresse gefunden. Prüf die Schreibweise oder gib sie von Hand ein. |
| manual | English Enter the address by hand | German Adresse von Hand eingeben |
| found | English {n} addresses found | German {n} Adressen gefunden |
Accessibility
Read from the source of the component.
- Address form handles the keys Escape, Enter, Arrow up, Arrow down and Space itself.
- Sets aria-activedescendant, aria-autocomplete, aria-controls, aria-expanded, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles combobox, listbox, option and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 684 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ErrorSummary, FlowHead, Unfold, type FlowError } from '@/components/ui/form-flow';
/*
* Addresses that fit the country. The fields, their order, their labels and
* the postcode check follow the country, so a German address reads like one
* and a US address too. Sends { address, billing? } to `onSubmit`.
*
* international the country decides fields and order, the form glides to the new set
* shipping-billing delivery address, billing the same or its own, business details on request
* address-book saved addresses as cards, a default, add a new one in place
* lookup type and pick a suggestion, the fields fill themselves, or enter by hand
*/
export type AddressFormVariant = 'international' | 'shipping-billing' | 'address-book' | 'lookup';
export interface Address {
name?: string;
company?: string;
line1: string;
line2?: string;
postcode: string;
city: string;
region?: string;
country: string;
}
export interface SavedAddress extends Address {
id: string;
label?: string;
isDefault?: boolean;
}
export interface AddressFormProps {
variant?: AddressFormVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** Country codes to offer, default DE, AT, CH, NL, FR, GB, US */
countries?: string[];
defaultCountry?: string;
/** address-book: saved addresses */
saved?: SavedAddress[];
/** lookup: addresses the search can find (your address service fills this) */
suggestions?: Address[];
submitLabel?: string;
onSubmit?: (data: { address: Address; billing?: Address; business?: { company: string; vat: string } }) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
country: 'Country',
name: 'Full name',
company: 'Company',
street: 'Street and number',
line1: 'Address',
line2: 'Apartment, suite, floor',
postcode: 'Postcode',
zip: 'ZIP code',
city: 'City',
state: 'State',
choose: 'Please choose',
nameNeeded: 'Please add a name, it goes on the parcel.',
streetNeeded: 'Please add the street and number, so the courier finds the door.',
cityNeeded: 'Please add the city.',
companyNeeded: 'Please add the company, the invoice is made out to it.',
stateNeeded: 'Please choose the state, the address is incomplete without it.',
postcodeNeeded: 'Please add the postcode.',
postcodeFormat: 'This postcode looks different from the usual {c} form, for example {e}. Please check it so nothing gets lost.',
save: 'Use this address',
saved: 'Address saved',
shipping: 'Delivery address',
billing: 'Billing address',
same: 'Billing address is the same',
business: 'I order as a business',
vat: 'VAT number',
vatHint: 'For an invoice without VAT inside the EU',
continue: 'Continue to payment',
default: 'Default',
makeDefault: 'Make default',
edit: 'Edit {l}',
remove: 'Remove {l}',
removeShort: 'Remove',
add: 'Add a new address',
cancel: 'Cancel',
deliverHere: 'Deliver here',
search: 'Search for your address',
searchHint: 'Street and number are enough',
noMatch: 'No address found. Check the spelling or enter it by hand.',
manual: 'Enter the address by hand',
found: '{n} addresses found',
},
de: {
country: 'Land',
name: 'Vor- und Nachname',
company: 'Firma',
street: 'Straße und Hausnummer',
line1: 'Adresse',
line2: 'Wohnung, Etage, Zusatz',
postcode: 'Postleitzahl',
zip: 'ZIP-Code',
city: 'Ort',
state: 'Bundesstaat',
choose: 'Bitte wählen',
nameNeeded: 'Bitte gib einen Namen an, er steht auf dem Paket.',
streetNeeded: 'Bitte gib Straße und Hausnummer an, damit der Bote die Tür findet.',
cityNeeded: 'Bitte gib den Ort an.',
companyNeeded: 'Bitte gib die Firma an, auf sie lautet die Rechnung.',
stateNeeded: 'Bitte wähle den Bundesstaat, ohne ihn ist die Adresse unvollständig.',
postcodeNeeded: 'Bitte gib die Postleitzahl an.',
postcodeFormat: 'Diese Postleitzahl sieht anders aus als die übliche Form für {c}, zum Beispiel {e}. Bitte prüf sie, damit nichts verloren geht.',
save: 'Diese Adresse verwenden',
saved: 'Adresse gespeichert',
shipping: 'Lieferadresse',
billing: 'Rechnungsadresse',
same: 'Rechnungsadresse ist dieselbe',
business: 'Ich bestelle als Firma',
vat: 'USt-IdNr.',
vatHint: 'Für eine Rechnung ohne Umsatzsteuer innerhalb der EU',
continue: 'Weiter zur Zahlung',
default: 'Standard',
makeDefault: 'Als Standard',
edit: '{l} bearbeiten',
remove: '{l} entfernen',
removeShort: 'Entfernen',
add: 'Neue Adresse hinzufügen',
cancel: 'Abbrechen',
deliverHere: 'Hierhin liefern',
search: 'Adresse suchen',
searchHint: 'Straße und Hausnummer reichen',
noMatch: 'Keine Adresse gefunden. Prüf die Schreibweise oder gib sie von Hand ein.',
manual: 'Adresse von Hand eingeben',
found: '{n} Adressen gefunden',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Countries */
/* ------------------------------------------------------------------ */
interface CountryRule {
name: { en: string; de: string };
/** Postcode before the city (most of Europe) or after (UK, US) */
postcodeFirst: boolean;
postcode: RegExp;
example: string;
zip?: boolean;
/** Regions to pick, e.g. US states */
regions?: string[];
/** Street then number (no extra line) or address lines */
lines: 'street' | 'lines';
}
const US_STATES = ['Alabama', 'Alaska', 'Arizona', 'California', 'Colorado', 'Florida', 'Georgia', 'Illinois', 'Massachusetts', 'Michigan', 'Minnesota', 'New York', 'North Carolina', 'Ohio', 'Oregon', 'Pennsylvania', 'Texas', 'Virginia', 'Washington'];
const COUNTRIES: Record<string, CountryRule> = {
DE: { name: { en: 'Germany', de: 'Deutschland' }, postcodeFirst: true, postcode: /^\d{5}$/, example: '10115', lines: 'street' },
AT: { name: { en: 'Austria', de: 'Österreich' }, postcodeFirst: true, postcode: /^\d{4}$/, example: '1010', lines: 'street' },
CH: { name: { en: 'Switzerland', de: 'Schweiz' }, postcodeFirst: true, postcode: /^\d{4}$/, example: '8001', lines: 'street' },
NL: { name: { en: 'Netherlands', de: 'Niederlande' }, postcodeFirst: true, postcode: /^\d{4}\s?[A-Za-z]{2}$/, example: '1012 AB', lines: 'street' },
FR: { name: { en: 'France', de: 'Frankreich' }, postcodeFirst: true, postcode: /^\d{5}$/, example: '75001', lines: 'lines' },
GB: { name: { en: 'United Kingdom', de: 'Vereinigtes Königreich' }, postcodeFirst: false, postcode: /^[A-Za-z]{1,2}\d[A-Za-z\d]?\s?\d[A-Za-z]{2}$/, example: 'SW1A 1AA', lines: 'lines' },
US: { name: { en: 'United States', de: 'Vereinigte Staaten' }, postcodeFirst: false, postcode: /^\d{5}(-\d{4})?$/, example: '97205', zip: true, regions: US_STATES, lines: 'lines' },
};
const empty = (country: string): Address => ({ name: '', line1: '', line2: '', postcode: '', city: '', region: '', country });
function checkAddress(a: Address, t: Texts, locale: 'en' | 'de', withName = true) {
const rule = COUNTRIES[a.country] ?? COUNTRIES.DE;
const e: Record<string, string> = {};
if (withName && !a.name?.trim()) e.name = t.nameNeeded;
if (!a.line1.trim()) e.line1 = t.streetNeeded;
if (!a.postcode.trim()) e.postcode = t.postcodeNeeded;
else if (!rule.postcode.test(a.postcode.trim())) e.postcode = t.postcodeFormat.replace('{c}', rule.name[locale]).replace('{e}', rule.example);
if (!a.city.trim()) e.city = t.cityNeeded;
if (rule.regions && !a.region) e.region = t.stateNeeded;
return e;
}
/** One line of an address as it is written in that country */
export function formatAddress(a: Address, locale: 'en' | 'de') {
const rule = COUNTRIES[a.country];
const place = rule && !rule.postcodeFirst ? [a.city, [a.region, a.postcode].filter(Boolean).join(' ')].filter(Boolean).join(', ') : `${a.postcode} ${a.city}`;
return [a.line1, a.line2, place, rule ? rule.name[locale] : a.country].filter(Boolean).join(', ');
}
/* ------------------------------------------------------------------ */
/* The fields for one country */
/* ------------------------------------------------------------------ */
function AddressFields({ id, value, onChange, errors, t, countries, withName = true, withCompany, section }: { id: string; value: Address; onChange: (a: Address) => void; errors: Record<string, string>; t: Texts; countries: string[]; withName?: boolean; withCompany?: boolean; /** autofill section when two addresses share a form */ section?: 'shipping' | 'billing' }) {
const locale = useLocale();
const ac = (token: string) => (section ? `${section} ${token}` : token);
const rule = COUNTRIES[value.country] ?? COUNTRIES.DE;
const ref = useRef<HTMLDivElement>(null);
const first = useRef(true);
// A new country lets its fields glide in
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const el = ref.current;
if (!el || typeof el.animate !== 'function' || prefersReducedMotion()) return;
el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 380, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}, [value.country]);
const set = (k: keyof Address) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => onChange({ ...value, [k]: e.target.value });
const postcode = <Input key="pc" id={`${id}-postcode`} label={rule.zip ? t.zip : t.postcode} required autoComplete={ac('postal-code')} placeholder={rule.example} value={value.postcode} onChange={set('postcode')} error={errors.postcode} className="sm:col-span-2" />;
const city = <Input key="city" id={`${id}-city`} label={t.city} required autoComplete={ac('address-level2')} value={value.city} onChange={set('city')} error={errors.city} className={rule.regions ? 'sm:col-span-2' : 'sm:col-span-4'} />;
const region = rule.regions ? <NativeSelect key="region" id={`${id}-region`} label={t.state} required autoComplete={ac('address-level1')} placeholder={t.choose} options={rule.regions.map((r) => ({ value: r, label: r }))} value={value.region ?? ''} onChange={set('region')} error={errors.region} className="sm:col-span-2" /> : null;
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-6">
<NativeSelect id={`${id}-country`} label={t.country} autoComplete={ac('country')} icon="globe" options={countries.map((c) => ({ value: c, label: COUNTRIES[c]?.name[locale] ?? c }))} value={value.country} onChange={(e) => onChange({ ...value, country: e.target.value, region: '' })} className="sm:col-span-6" />
<div ref={ref} className="contents">
{withName && <Input id={`${id}-name`} label={t.name} required autoComplete={ac('name')} value={value.name ?? ''} onChange={set('name')} error={errors.name} className={withCompany ? 'sm:col-span-3' : 'sm:col-span-6'} />}
{withCompany && <Input label={t.company} optional autoComplete={ac('organization')} value={value.company ?? ''} onChange={set('company')} className="sm:col-span-3" />}
<Input id={`${id}-line1`} label={rule.lines === 'street' ? t.street : t.line1} required autoComplete={ac('address-line1')} value={value.line1} onChange={set('line1')} error={errors.line1} className="sm:col-span-6" />
{rule.lines === 'lines' && <Input label={t.line2} optional autoComplete={ac('address-line2')} value={value.line2 ?? ''} onChange={set('line2')} className="sm:col-span-6" />}
{rule.postcodeFirst ? (
<>
{postcode}
{city}
</>
) : (
<>
{city}
{region}
{postcode}
</>
)}
</div>
</div>
);
}
function useCheck() {
const [errors, setErrors] = useState<Record<string, string>>({});
const summaryRef = useRef<HTMLDivElement>(null);
const fail = (found: Record<string, string>) => {
setErrors(found);
if (Object.keys(found).length) {
requestAnimationFrame(() => summaryRef.current?.focus());
return true;
}
return false;
};
return { errors, setErrors, fail, summaryRef };
}
const LABEL_KEY: Record<string, keyof Texts> = { name: 'name', line1: 'street', postcode: 'postcode', city: 'city', region: 'state', company: 'company' };
function summaryFor(prefix: string, errors: Record<string, string>, t: Texts, map?: Record<string, string>): FlowError[] {
return Object.keys(errors).map((k) => {
const [scope, field] = k.includes('.') ? k.split('.') : ['', k];
return { field: `${prefix}${scope ? `-${scope}` : ''}-${field}`, message: `${map?.[scope] ? `${map[scope]} · ` : ''}${t[LABEL_KEY[field] ?? 'street']}` };
});
}
function Saved({ a, t }: { a: Address; t: Texts }) {
const locale = useLocale();
return (
<p role="status" className="kit-rise mt-5 flex items-start gap-3 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_7%,var(--card))] p-4 text-[14px]">
<Icon name="check-circle" size={18} className="mt-px shrink-0 text-success" />
<span>
<span className="block font-semibold text-foreground">{t.saved}</span>
<span className="mt-0.5 block text-muted-foreground">{[a.name, formatAddress(a, locale)].filter(Boolean).join(', ')}</span>
</span>
</p>
);
}
/* ------------------------------------------------------------------ */
/* international */
/* ------------------------------------------------------------------ */
function International({ p, t, countries }: { p: AddressFormProps; t: Texts; countries: string[] }) {
const id = useId();
const locale = useLocale();
const c = useCheck();
const [a, setA] = useState<Address>(empty(p.defaultCountry ?? countries[0]));
const [done, setDone] = useState<Address | null>(null);
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
noValidate
className="kit-card p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
if (c.fail(checkAddress(a, t, locale))) return;
p.onSubmit?.({ address: a });
setDone(a);
}}
>
<ErrorSummary ref={c.summaryRef} errors={summaryFor(id, c.errors, t)} className="mb-6" />
<AddressFields
id={id}
value={a}
onChange={(x) => {
setA(x);
setDone(null);
if (Object.keys(c.errors).length) c.setErrors(checkAddress(x, t, locale));
}}
errors={c.errors}
t={t}
countries={countries}
/>
<div className="mt-7 border-t border-border pt-6">
<Button type="submit" icon="check" className="max-sm:w-full">
{p.submitLabel ?? t.save}
</Button>
</div>
{done && <Saved a={done} t={t} />}
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* shipping-billing */
/* ------------------------------------------------------------------ */
function ShippingBilling({ p, t, countries }: { p: AddressFormProps; t: Texts; countries: string[] }) {
const id = useId();
const locale = useLocale();
const c = useCheck();
const [ship, setShip] = useState<Address>(empty(p.defaultCountry ?? countries[0]));
const [bill, setBill] = useState<Address>(empty(p.defaultCountry ?? countries[0]));
const [same, setSame] = useState(true);
const [business, setBusiness] = useState(false);
const [biz, setBiz] = useState({ company: '', vat: '' });
const [done, setDone] = useState(false);
const check = () => {
const e: Record<string, string> = {};
for (const [k, v] of Object.entries(checkAddress(ship, t, locale))) e[`ship.${k}`] = v;
if (business && !biz.company.trim()) e['biz.company'] = t.companyNeeded;
if (!same) for (const [k, v] of Object.entries(checkAddress(bill, t, locale))) e[`bill.${k}`] = v;
return e;
};
// After a first send, every change checks again, so fixed fields lose their message at once
const live = Object.keys(c.errors).length > 0;
useEffect(() => {
if (live) c.setErrors(check());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ship, bill, same, business, biz]);
const scoped = (scope: string) => Object.fromEntries(Object.entries(c.errors).filter(([k]) => k.startsWith(`${scope}.`)).map(([k, v]) => [k.split('.')[1], v]));
return (
<Reveal effect="rise" className="mx-auto max-w-[44rem]">
<form
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
if (c.fail(check())) return;
p.onSubmit?.({ address: ship, billing: same ? undefined : bill, business: business ? biz : undefined });
setDone(true);
}}
>
<div className="grid gap-7 p-6 sm:p-8">
<ErrorSummary ref={c.summaryRef} errors={summaryFor(id, c.errors, t, { ship: t.shipping, bill: t.billing })} />
<section aria-labelledby={`${id}-sh`}>
<h3 id={`${id}-sh`} className="mb-5 flex items-center gap-2.5 text-[16px] font-semibold text-foreground">
<Icon name="truck" size={18} className="text-muted-foreground" />
{t.shipping}
</h3>
<AddressFields id={`${id}-ship`} section="shipping" value={ship} onChange={(x) => (setShip(x), setDone(false))} errors={scoped('ship')} t={t} countries={countries} />
</section>
<div>
<div className="grid gap-3 border-t border-border pt-6">
<Checkbox label={t.same} checked={same} onChange={(e) => setSame(e.target.checked)} />
<Checkbox label={t.business} checked={business} onChange={(e) => setBusiness(e.target.checked)} />
</div>
{/* Business details and a separate billing address open with a soft height, closed they take no room */}
<Unfold open={business}>
<div className="grid grid-cols-1 gap-5 pt-7 sm:grid-cols-2">
<Input id={`${id}-biz-company`} label={t.company} required autoComplete="billing organization" value={biz.company} onChange={(e) => setBiz((b) => ({ ...b, company: e.target.value }))} error={c.errors['biz.company']} />
<Input label={t.vat} optional hint={t.vatHint} placeholder="DE123456789" value={biz.vat} onChange={(e) => setBiz((b) => ({ ...b, vat: e.target.value }))} />
</div>
</Unfold>
<Unfold open={!same}>
<div className="pt-7">
<section aria-labelledby={`${id}-bi`} className="rounded-xl border border-border bg-surface p-5 sm:p-6">
<h3 id={`${id}-bi`} className="mb-5 flex items-center gap-2.5 text-[16px] font-semibold text-foreground">
<Icon name="receipt" size={18} className="text-muted-foreground" />
{t.billing}
</h3>
<AddressFields id={`${id}-bill`} section="billing" value={bill} onChange={setBill} errors={scoped('bill')} t={t} countries={countries} />
</section>
</div>
</Unfold>
</div>
</div>
<div className="flex flex-col gap-3 border-t border-border bg-surface px-6 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p aria-live="polite" className="min-w-0 text-[13.5px] text-muted-foreground">
{done ? (
<span className="kit-rise inline-flex items-center gap-2 text-success-foreground">
<Icon name="check-circle" size={16} />
{t.saved}
</span>
) : ship.line1 ? (
<span className="line-clamp-1">{formatAddress(ship, locale)}</span>
) : null}
</p>
<Button type="submit" icon="arrow-right" className="max-sm:w-full">
{p.submitLabel ?? t.continue}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* address-book */
/* ------------------------------------------------------------------ */
function AddressBook({ p, t, countries }: { p: AddressFormProps; t: Texts; countries: string[] }) {
const id = useId();
const locale = useLocale();
const c = useCheck();
const [list, setList] = useState<SavedAddress[]>(p.saved ?? []);
const [pick, setPick] = useState(list.find((x) => x.isDefault)?.id ?? list[0]?.id ?? '');
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState<Address>(empty(p.defaultCountry ?? countries[0]));
const addBtn = useRef<HTMLButtonElement>(null);
const setDefault = (aid: string) => setList((l) => l.map((x) => ({ ...x, isDefault: x.id === aid })));
return (
<div className="mx-auto max-w-[52rem]">
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
const chosen = list.find((x) => x.id === pick);
if (chosen) p.onSubmit?.({ address: chosen });
}}
>
<fieldset className="min-w-0">
<legend className="sr-only">{t.shipping}</legend>
<Stagger step={80} as="div" className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{list.map((a) => {
const on = pick === a.id;
const label = a.label ?? a.name ?? '';
return (
<div key={a.id} className={cn('relative flex h-full flex-col rounded-2xl border bg-card p-5 shadow-[var(--shadow-sm)] transition-[border-color,box-shadow] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,var(--border)),var(--shadow-md)]' : 'border-border hover:border-border-strong', 'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]')}>
<label className="flex flex-1 cursor-pointer items-start gap-3.5">
<input type="radio" name={`${id}-pick`} checked={on} onChange={() => setPick(a.id)} className="peer sr-only" />
<span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
<span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-100' : 'scale-0')} />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-[15px] font-semibold text-foreground">{label}</span>
{a.isDefault && <span className="rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] px-2 py-px text-[11.5px] font-medium text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{t.default}</span>}
</span>
<span className="mt-1.5 block text-pretty text-[13.5px] leading-relaxed text-muted-foreground">
{a.name}
<br />
{formatAddress(a, locale)}
</span>
</span>
</label>
<div className="mt-4 flex flex-wrap gap-1 border-t border-border pt-3 ps-8">
{!a.isDefault && (
<Button type="button" variant="ghost" size="sm" onClick={() => setDefault(a.id)} className="-ms-2">
{t.makeDefault}
</Button>
)}
<Button type="button" variant="ghost" size="sm" iconLeft="trash" aria-label={t.remove.replace('{l}', label)} onClick={() => setList((l) => l.filter((x) => x.id !== a.id))} className={cn('text-muted-foreground', a.isDefault && '-ms-2')}>
<span className="sr-only sm:not-sr-only">{t.removeShort}</span>
</Button>
</div>
</div>
);
})}
{/* The add card turns into a form in place */}
{!adding && (
<button ref={addBtn} type="button" onClick={() => setAdding(true)} className="group/add flex min-h-28 flex-col sm:min-h-[11rem] items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-border-strong bg-transparent p-5 text-[14px] font-medium text-muted-foreground transition-[border-color,color,background-color] duration-300 hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))] hover:bg-[color-mix(in_oklab,var(--primary)_4%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none">
<span className="grid size-11 place-items-center rounded-full bg-surface-2 transition-transform duration-500 ease-[cubic-bezier(0.34,1.6,0.64,1)] group-hover/add:rotate-90">
<Icon name="plus" size={20} />
</span>
{t.add}
</button>
)}
</Stagger>
</fieldset>
{list.length > 0 && !adding && (
<div className="mt-6 flex justify-end">
<Button type="submit" icon="arrow-right" className="max-sm:w-full">
{p.submitLabel ?? t.deliverHere}
</Button>
</div>
)}
</form>
<Unfold open={adding}>
<form
noValidate
className="kit-card mt-4 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
if (c.fail(checkAddress(draft, t, locale))) return;
const nid = `${Date.now()}`;
setList((l) => [...l, { ...draft, id: nid, label: draft.name }]);
setPick(nid);
setDraft(empty(draft.country));
setAdding(false);
}}
>
<ErrorSummary ref={c.summaryRef} errors={summaryFor(`${id}-new`, c.errors, t)} className="mb-6" />
{adding && <AddressFields id={`${id}-new`} value={draft} onChange={(x) => (setDraft(x), Object.keys(c.errors).length > 0 && c.setErrors(checkAddress(x, t, locale)))} errors={c.errors} t={t} countries={countries} />}
<div className="mt-7 flex flex-col-reverse gap-3 border-t border-border pt-6 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" onClick={() => (setAdding(false), c.setErrors({}), requestAnimationFrame(() => addBtn.current?.focus()))}>
{t.cancel}
</Button>
<Button type="submit" icon="check" className="max-sm:w-full">
{t.save}
</Button>
</div>
</form>
</Unfold>
</div>
);
}
/* ------------------------------------------------------------------ */
/* lookup */
/* ------------------------------------------------------------------ */
function Lookup({ p, t, countries }: { p: AddressFormProps; t: Texts; countries: string[] }) {
const id = useId();
const locale = useLocale();
const c = useCheck();
const [q, setQ] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [manual, setManual] = useState(false);
const [a, setA] = useState<Address>(empty(p.defaultCountry ?? countries[0]));
const [done, setDone] = useState<Address | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
const matches = words.length ? (p.suggestions ?? []).filter((s) => words.every((w) => formatAddress(s, locale).toLowerCase().includes(w))).slice(0, 5) : [];
const choose = (s: Address) => {
setA({ ...s, name: a.name });
setManual(true);
setOpen(false);
setQ(formatAddress(s, locale));
c.setErrors({});
requestAnimationFrame(() => document.getElementById(`${id}-name`)?.focus());
};
// Highlight the typed words inside each suggestion
const mark = (text: string) => {
if (!words.length) return text;
const re = new RegExp(`(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig');
return text.split(re).map((part, i) => (i % 2 ? <mark key={i} className="rounded-sm bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] text-foreground">{part}</mark> : part));
};
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
noValidate
className="kit-card p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
if (!manual) {
setManual(true);
return;
}
if (c.fail(checkAddress(a, t, locale))) return;
p.onSubmit?.({ address: a });
setDone(a);
}}
>
{/* A combobox: arrow keys move through the suggestions, Enter picks, Escape closes */}
<div className="relative">
<Input
ref={inputRef}
id={`${id}-search`}
label={t.search}
hint={t.searchHint}
icon="search"
size="lg"
role="combobox"
aria-expanded={open && matches.length > 0}
aria-controls={`${id}-list`}
aria-autocomplete="list"
aria-activedescendant={open && matches[active] ? `${id}-opt-${active}` : undefined}
autoComplete="off"
value={q}
onChange={(e) => (setQ(e.target.value), setOpen(true), setActive(0))}
onFocus={() => q && setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
onKeyDown={(e) => {
if (!open || !matches.length) return;
if (e.key === 'ArrowDown') (e.preventDefault(), setActive((x) => (x + 1) % matches.length));
else if (e.key === 'ArrowUp') (e.preventDefault(), setActive((x) => (x - 1 + matches.length) % matches.length));
else if (e.key === 'Enter') (e.preventDefault(), choose(matches[active]));
else if (e.key === 'Escape') setOpen(false);
}}
/>
{open && q.trim().length > 0 && (
<div className="kit-rise absolute inset-x-0 top-[5.25rem] z-30 overflow-hidden rounded-xl border border-border bg-popover shadow-[var(--shadow-lg)]">
{matches.length ? (
<ul id={`${id}-list`} role="listbox" aria-label={t.found.replace('{n}', String(matches.length))} className="py-1.5">
{matches.map((s, i) => (
<li
key={formatAddress(s, locale)}
id={`${id}-opt-${i}`}
role="option"
aria-selected={i === active}
onPointerDown={(e) => (e.preventDefault(), choose(s))}
onPointerEnter={() => setActive(i)}
className={cn('flex min-h-11 cursor-pointer items-center gap-3 px-3.5 py-2 text-[14px] text-foreground', i === active && 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)]')}
>
<Icon name="map-pin" size={16} className="shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{mark(formatAddress(s, locale))}</span>
</li>
))}
</ul>
) : (
<p className="px-4 py-3.5 text-[13.5px] text-muted-foreground">{t.noMatch}</p>
)}
</div>
)}
</div>
{!manual && (
<button type="button" onClick={() => (setManual(true), requestAnimationFrame(() => document.getElementById(`${id}-name`)?.focus()))} className="mt-4 inline-flex min-h-11 items-center gap-2 text-[14px] font-medium text-foreground underline-offset-4 hover:underline focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
<Icon name="edit" size={15} />
{t.manual}
</button>
)}
{/* The fields fold out once an address is picked or typed by hand */}
<Unfold open={manual}>
<div className="pt-6">
<ErrorSummary ref={c.summaryRef} errors={summaryFor(id, c.errors, t)} className="mb-6" />
<AddressFields id={id} value={a} onChange={(x) => (setA(x), setDone(null), Object.keys(c.errors).length > 0 && c.setErrors(checkAddress(x, t, locale)))} errors={c.errors} t={t} countries={countries} />
</div>
</Unfold>
{manual && (
<div className="kit-rise mt-7 border-t border-border pt-6">
<Button type="submit" icon="check" className="max-sm:w-full">
{p.submitLabel ?? t.save}
</Button>
</div>
)}
{done && <Saved a={done} t={t} />}
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* Addresses that fit the country.
* <AddressForm variant="international" countries={['DE', 'AT', 'US']} onSubmit={({ address }) => …} />
*/
export function AddressForm(props: AddressFormProps) {
const { variant = 'international', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const countries = (props.countries ?? Object.keys(COUNTRIES)).filter((c) => COUNTRIES[c]);
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
{variant === 'international' && <International p={props} t={t} countries={countries} />}
{variant === 'shipping-billing' && <ShippingBilling p={props} t={t} countries={countries} />}
{variant === 'address-book' && <AddressBook p={props} t={t} countries={countries} />}
{variant === 'lookup' && <Lookup p={props} t={t} countries={countries} />}
</Section>
);
}Frequent questions
How do I install Address form?
With aiw add @ai-website/address-form. The command puts components/ui/address-form.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input, select and checkbox. It needs no further npm packages.
Which variants does Address form have?
4 variants, International, Shipping and billing, Address book and Address search. You pick one with the variant prop, for example variant="shipping-billing".
Can I translate the texts of Address form?
Yes. Address form carries 40 texts of its own in English and German, such as “Country”, “Full name” and “Company”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Address form?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Address form with aiw_components and install it with aiw_add @ai-website/address-form. Name the variant in your prompt, for example variant="international", and the assistant writes your content straight into the props.
Can I adjust Address form without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching AddressForm code to copy.