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
Variants
Details
Was Adressformular mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Adresse, Zahlung und Einwilligung
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/address-form.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 40 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AddressFormProps aus components/ui/address-form.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Adressformular selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 40 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| country | Englisch Country | Deutsch Land |
| name | Englisch Full name | Deutsch Vor- und Nachname |
| company | Englisch Company | Deutsch Firma |
| street | Englisch Street and number | Deutsch Straße und Hausnummer |
| line1 | Englisch Address | Deutsch Adresse |
| line2 | Englisch Apartment, suite, floor | Deutsch Wohnung, Etage, Zusatz |
| postcode | Englisch Postcode | Deutsch Postleitzahl |
| zip | Englisch ZIP code | Deutsch ZIP-Code |
| city | Englisch City | Deutsch Ort |
| state | Englisch State | Deutsch Bundesstaat |
| choose | Englisch Please choose | Deutsch Bitte wählen |
| nameNeeded | Englisch Please add a name, it goes on the parcel. | Deutsch Bitte gib einen Namen an, er steht auf dem Paket. |
| streetNeeded | Englisch 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. |
| cityNeeded | Englisch Please add the city. | Deutsch Bitte gib den Ort an. |
| companyNeeded | Englisch Please add the company, the invoice is made out to it. | Deutsch Bitte gib die Firma an, auf sie lautet die Rechnung. |
| stateNeeded | Englisch Please choose the state, the address is incomplete without it. | Deutsch Bitte wähle den Bundesstaat, ohne ihn ist die Adresse unvollständig. |
| postcodeNeeded | Englisch Please add the postcode. | Deutsch Bitte gib die Postleitzahl an. |
| postcodeFormat | Englisch 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. |
| save | Englisch Use this address | Deutsch Diese Adresse verwenden |
| saved | Englisch Address saved | Deutsch Adresse gespeichert |
| shipping | Englisch Delivery address | Deutsch Lieferadresse |
| billing | Englisch Billing address | Deutsch Rechnungsadresse |
| same | Englisch Billing address is the same | Deutsch Rechnungsadresse ist dieselbe |
| business | Englisch I order as a business | Deutsch Ich bestelle als Firma |
| vat | Englisch VAT number | Deutsch USt-IdNr. |
| vatHint | Englisch For an invoice without VAT inside the EU | Deutsch Für eine Rechnung ohne Umsatzsteuer innerhalb der EU |
| continue | Englisch Continue to payment | Deutsch Weiter zur Zahlung |
| default | Englisch Default | Deutsch Standard |
| makeDefault | Englisch Make default | Deutsch Als Standard |
| edit | Englisch Edit {l} | Deutsch {l} bearbeiten |
| remove | Englisch Remove {l} | Deutsch {l} entfernen |
| removeShort | Englisch Remove | Deutsch Entfernen |
| add | Englisch Add a new address | Deutsch Neue Adresse hinzufügen |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| deliverHere | Englisch Deliver here | Deutsch Hierhin liefern |
| search | Englisch Search for your address | Deutsch Adresse suchen |
| searchHint | Englisch Street and number are enough | Deutsch Straße und Hausnummer reichen |
| noMatch | Englisch 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. |
| manual | Englisch Enter the address by hand | Deutsch Adresse von Hand eingeben |
| found | Englisch {n} addresses found | Deutsch {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
'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.