Zum Inhalt springen
UI Kit
Accent

Adressformular

Adressen, die zum Land passen. Felder, Reihenfolge, Bezeichnungen und die Prüfung der Postleitzahl folgen dem Land, Liefer- und Rechnungsadresse trennen sich bei Bedarf, gespeicherte Adressen sind Karten, und eine Suche füllt alles aus.

Eine von 3 Components in Adresse, Zahlung und Einwilligung

4 Variants

Variants

International

Das Land bestimmt die Felder. Deutschland fragt nach Straße und Hausnummer mit Postleitzahl vor dem Ort, die USA nach Adresszeilen, Stadt, Bundesstaat und ZIP. Die Postleitzahl wird mit Beispiel gegen das Land geprüft.

Im Studio öffnen

Lieferung und Rechnung

Zuerst die Lieferadresse, die Rechnung geht standardmäßig dorthin. Abwählen öffnet eine Rechnungsadresse in eigener Fläche, als Firma bestellen ergänzt Firma und USt-IdNr., der Fuß wiederholt die Adresse in einer Zeile.

Im Studio öffnen

Adressbuch

Gespeicherte Adressen als Karten mit Standard-Kennzeichen, die gewählte trägt einen Akzentring. Die Karte zum Hinzufügen dreht ihr Plus beim Darüberfahren und öffnet ein volles Formular an Ort und Stelle, eine neue Adresse ist sofort gewählt.

Im Studio öffnen

Adresssuche

Ein paar Worte tippen und einen Vorschlag wählen, die getippten Worte sind darin markiert. Pfeiltasten bewegen durch die Liste, Enter wählt, und die Felder klappen gefüllt auf. Von Hand eingeben ist einen Klick entfernt.

Im Studio öffnen

Details

Was Adressformular mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/address-form.tsx
Bringt mit
utilskitbuttoniconrevealsectionmotionform-flowinputselectcheckbox
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 40 eigene Texte

Props

Die Schnittstelle AddressFormProps aus components/ui/address-form.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Adressformular selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 40 Texte anzeigen
countryEnglisch CountryDeutsch Land
nameEnglisch Full nameDeutsch Vor- und Nachname
companyEnglisch CompanyDeutsch Firma
streetEnglisch Street and numberDeutsch Straße und Hausnummer
line1Englisch AddressDeutsch Adresse
line2Englisch Apartment, suite, floorDeutsch Wohnung, Etage, Zusatz
postcodeEnglisch PostcodeDeutsch Postleitzahl
zipEnglisch ZIP codeDeutsch ZIP-Code
cityEnglisch CityDeutsch Ort
stateEnglisch StateDeutsch Bundesstaat
chooseEnglisch Please chooseDeutsch Bitte wählen
nameNeededEnglisch Please add a name, it goes on the parcel.Deutsch Bitte gib einen Namen an, er steht auf dem Paket.
streetNeededEnglisch Please add the street and number, so the courier finds the door.Deutsch Bitte gib Straße und Hausnummer an, damit der Bote die Tür findet.
cityNeededEnglisch Please add the city.Deutsch Bitte gib den Ort an.
companyNeededEnglisch Please add the company, the invoice is made out to it.Deutsch Bitte gib die Firma an, auf sie lautet die Rechnung.
stateNeededEnglisch Please choose the state, the address is incomplete without it.Deutsch Bitte wähle den Bundesstaat, ohne ihn ist die Adresse unvollständig.
postcodeNeededEnglisch Please add the postcode.Deutsch Bitte gib die Postleitzahl an.
postcodeFormatEnglisch This postcode looks different from the usual {c} form, for example {e}. Please check it so nothing gets lost.Deutsch Diese Postleitzahl sieht anders aus als die übliche Form für {c}, zum Beispiel {e}. Bitte prüf sie, damit nichts verloren geht.
saveEnglisch Use this addressDeutsch Diese Adresse verwenden
savedEnglisch Address savedDeutsch Adresse gespeichert
shippingEnglisch Delivery addressDeutsch Lieferadresse
billingEnglisch Billing addressDeutsch Rechnungsadresse
sameEnglisch Billing address is the sameDeutsch Rechnungsadresse ist dieselbe
businessEnglisch I order as a businessDeutsch Ich bestelle als Firma
vatEnglisch VAT numberDeutsch USt-IdNr.
vatHintEnglisch For an invoice without VAT inside the EUDeutsch Für eine Rechnung ohne Umsatzsteuer innerhalb der EU
continueEnglisch Continue to paymentDeutsch Weiter zur Zahlung
defaultEnglisch DefaultDeutsch Standard
makeDefaultEnglisch Make defaultDeutsch Als Standard
editEnglisch Edit {l}Deutsch {l} bearbeiten
removeEnglisch Remove {l}Deutsch {l} entfernen
removeShortEnglisch RemoveDeutsch Entfernen
addEnglisch Add a new addressDeutsch Neue Adresse hinzufügen
cancelEnglisch CancelDeutsch Abbrechen
deliverHereEnglisch Deliver hereDeutsch Hierhin liefern
searchEnglisch Search for your addressDeutsch Adresse suchen
searchHintEnglisch Street and number are enoughDeutsch Straße und Hausnummer reichen
noMatchEnglisch No address found. Check the spelling or enter it by hand.Deutsch Keine Adresse gefunden. Prüf die Schreibweise oder gib sie von Hand ein.
manualEnglisch Enter the address by handDeutsch Adresse von Hand eingeben
foundEnglisch {n} addresses foundDeutsch {n} Adressen gefunden

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Adressformular reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter und Leertaste.
  • Setzt aria-activedescendant, aria-autocomplete, aria-controls, aria-expanded, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen combobox, listbox, option und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 684 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Adressformular?

Mit aiw add @ai-website/address-form. Der Befehl legt die Datei components/ui/address-form.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow, input, select und checkbox. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Adressformular?

4 Varianten, International, Lieferung und Rechnung, Adressbuch und Adresssuche. Du wählst sie mit der Prop variant, zum Beispiel variant="shipping-billing".

Kann ich die Texte von Adressformular übersetzen?

Ja. Adressformular bringt 40 eigene Texte auf Deutsch und Englisch mit, etwa „Land“, „Vor- und Nachname“ und „Firma“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Adressformular einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Adressformular mit aiw_components und installieren sie mit aiw_add @ai-website/address-form. Nenn im Prompt die Variante, etwa variant="international", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Adressformular ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden AddressForm-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert