Skip to content
UI Kit
Accent color

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

4 variants

Variants

International

The country decides the fields. Germany asks for street and number with the postcode before the city, the US for address lines, city, state and ZIP. The postcode is checked against the country with an example.

Open in Studio

Shipping and billing

The delivery address first, billing the same by default. Unticking opens a billing address in its own surface, ordering as a business adds company and VAT number, the footer repeats the address in one line.

Open in Studio

Address book

Saved addresses as cards with a default badge, the chosen one holds an accent ring. The add card turns its plus while hovered and opens a full form in place, a new address is chosen right away.

Open in Studio

Address search

Type a few words and pick a suggestion, the typed words are marked in each. Arrow keys move through the list, Enter picks, and the fields fold out filled. Entering by hand is one click away.

Open in Studio

Details

What Address form brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/address-form.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowinputselectcheckbox
npm packages
None
Languages
English and German, 40 texts of its own

Props

The AddressFormProps interface from components/ui/address-form.tsx, with the comments from the code.

  • variantoptional
    AddressFormVariant

    internationalshipping-billingaddress-booklookup

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • countriesoptional
    string[]

    Country codes to offer, default DE, AT, CH, NL, FR, GB, US

  • defaultCountryoptional
    string
  • savedoptional
    SavedAddress[]

    address-book: saved addresses

  • suggestionsoptional
    Address[]

    lookup: addresses the search can find (your address service fills this)

  • submitLabeloptional
    string
  • onSubmitoptional
    (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
countryEnglish CountryGerman Land
nameEnglish Full nameGerman Vor- und Nachname
companyEnglish CompanyGerman Firma
streetEnglish Street and numberGerman Straße und Hausnummer
line1English AddressGerman Adresse
line2English Apartment, suite, floorGerman Wohnung, Etage, Zusatz
postcodeEnglish PostcodeGerman Postleitzahl
zipEnglish ZIP codeGerman ZIP-Code
cityEnglish CityGerman Ort
stateEnglish StateGerman Bundesstaat
chooseEnglish Please chooseGerman Bitte wählen
nameNeededEnglish Please add a name, it goes on the parcel.German Bitte gib einen Namen an, er steht auf dem Paket.
streetNeededEnglish 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.
cityNeededEnglish Please add the city.German Bitte gib den Ort an.
companyNeededEnglish Please add the company, the invoice is made out to it.German Bitte gib die Firma an, auf sie lautet die Rechnung.
stateNeededEnglish Please choose the state, the address is incomplete without it.German Bitte wähle den Bundesstaat, ohne ihn ist die Adresse unvollständig.
postcodeNeededEnglish Please add the postcode.German Bitte gib die Postleitzahl an.
postcodeFormatEnglish 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.
saveEnglish Use this addressGerman Diese Adresse verwenden
savedEnglish Address savedGerman Adresse gespeichert
shippingEnglish Delivery addressGerman Lieferadresse
billingEnglish Billing addressGerman Rechnungsadresse
sameEnglish Billing address is the sameGerman Rechnungsadresse ist dieselbe
businessEnglish I order as a businessGerman Ich bestelle als Firma
vatEnglish VAT numberGerman USt-IdNr.
vatHintEnglish For an invoice without VAT inside the EUGerman Für eine Rechnung ohne Umsatzsteuer innerhalb der EU
continueEnglish Continue to paymentGerman Weiter zur Zahlung
defaultEnglish DefaultGerman Standard
makeDefaultEnglish Make defaultGerman Als Standard
editEnglish Edit {l}German {l} bearbeiten
removeEnglish Remove {l}German {l} entfernen
removeShortEnglish RemoveGerman Entfernen
addEnglish Add a new addressGerman Neue Adresse hinzufügen
cancelEnglish CancelGerman Abbrechen
deliverHereEnglish Deliver hereGerman Hierhin liefern
searchEnglish Search for your addressGerman Adresse suchen
searchHintEnglish Street and number are enoughGerman Straße und Hausnummer reichen
noMatchEnglish 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.
manualEnglish Enter the address by handGerman Adresse von Hand eingeben
foundEnglish {n} addresses foundGerman {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
components/ui/address-form.tsx
'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.

Often searched

Jump to

Copied to the clipboard