Shipping address
Country-aware postcode validation and autocomplete tokens on every field.
Live preview
Source
This exact file renders the preview above.
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { useDemoForm } from '@/hooks/use-demo-form'
import { phone, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Shipping address
*
* Ordered the way an address is written, with `autoComplete` tokens on every
* field so browser and password-manager autofill actually work — the single
* highest-impact change available to a checkout form.
*
* The postcode rule adapts to the selected country rather than applying one
* regex to the world.
*/
const postcodeRules: Record<string, { regex: RegExp; message: string; label: string }> = {
gb: {
regex: /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i,
message: 'Enter a valid UK postcode, for example EC2A 4NE.',
label: 'Postcode',
},
us: {
regex: /^\d{5}(-\d{4})?$/,
message: 'Enter a valid ZIP code, for example 94107.',
label: 'ZIP code',
},
de: {
regex: /^\d{5}$/,
message: 'Enter a valid five-digit Postleitzahl.',
label: 'Postleitzahl',
},
ie: {
regex: /^[A-Z]\d{2}\s?[A-Z\d]{4}$/i,
message: 'Enter a valid Eircode, for example D02 AF30.',
label: 'Eircode',
},
}
export default function ShippingAddressForm() {
const form = useDemoForm({
schema: {
name: { validators: [required('Full name')] },
line1: { validators: [required('Address')] },
line2: {},
city: { validators: [required('City')] },
country: { initial: 'gb', validators: [required('Country')] },
postcode: {
validators: [
required('Postcode'),
(value, values) => {
const rule = postcodeRules[values.country ?? 'gb']
if (!rule || !value.trim()) return null
return rule.regex.test(value.trim()) ? null : rule.message
},
],
},
phone: { validators: [phone()] },
billingSame: { initial: 'true' },
},
})
const rule = postcodeRules[form.values.country ?? 'gb']
return (
<FormShell
title="Shipping address"
description="Where the order should go."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Save address"
submittingLabel="Saving"
onReset={form.reset}
successTitle="Address saved"
width="lg"
>
<Field name="name" label="Full name" required error={form.error('name')}>
{(field) => <Input {...field} autoComplete="shipping name" {...form.field('name')} />}
</Field>
<Field name="line1" label="Address" required error={form.error('line1')}>
{(field) => (
<Input {...field} autoComplete="shipping address-line1" {...form.field('line1')} />
)}
</Field>
<Field name="line2" label="Apartment, floor, company" showOptional>
{(field) => (
<Input {...field} autoComplete="shipping address-line2" {...form.field('line2')} />
)}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="city" label="City" required error={form.error('city')}>
{(field) => (
<Input {...field} autoComplete="shipping address-level2" {...form.field('city')} />
)}
</Field>
<Field name="country" label="Country" required error={form.error('country')}>
{(field) => (
<Select
{...field}
{...form.field('country')}
options={[
{ value: 'gb', label: 'United Kingdom' },
{ value: 'ie', label: 'Ireland' },
{ value: 'de', label: 'Germany' },
{ value: 'us', label: 'United States' },
]}
/>
)}
</Field>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field
name="postcode"
label={rule?.label ?? 'Postcode'}
required
error={form.error('postcode')}
hint="Validation follows the selected country."
>
{(field) => (
<Input
{...field}
autoComplete="shipping postal-code"
className="font-mono"
{...form.field('postcode')}
/>
)}
</Field>
<Field
name="phone"
label="Phone"
showOptional
error={form.error('phone')}
hint="For delivery updates only."
>
{(field) => (
<Input {...field} type="tel" autoComplete="shipping tel" {...form.field('phone')} />
)}
</Field>
</div>
<Fieldset legend="Billing" name="billingSame">
<Checkbox
id="billing-same"
name="billingSame"
label="Billing address is the same"
checked={form.values.billingSame === 'true'}
onChange={(event) => form.setValue('billingSame', String(event.target.checked))}
/>
</Fieldset>
</FormShell>
)
}
components/blocks/forms/shipping-address.tsx
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { useDemoForm } from '@/hooks/use-demo-form'
import { phone, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Shipping address
*
* Ordered the way an address is written, with `autoComplete` tokens on every
* field so browser and password-manager autofill actually work — the single
* highest-impact change available to a checkout form.
*
* The postcode rule adapts to the selected country rather than applying one
* regex to the world.
*/
const postcodeRules: Record<string, { regex: RegExp; message: string; label: string }> = {
gb: {
regex: /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i,
message: 'Enter a valid UK postcode, for example EC2A 4NE.',
label: 'Postcode',
},
us: {
regex: /^\d{5}(-\d{4})?$/,
message: 'Enter a valid ZIP code, for example 94107.',
label: 'ZIP code',
},
de: {
regex: /^\d{5}$/,
message: 'Enter a valid five-digit Postleitzahl.',
label: 'Postleitzahl',
},
ie: {
regex: /^[A-Z]\d{2}\s?[A-Z\d]{4}$/i,
message: 'Enter a valid Eircode, for example D02 AF30.',
label: 'Eircode',
},
}
export default function ShippingAddressForm() {
const form = useDemoForm({
schema: {
name: { validators: [required('Full name')] },
line1: { validators: [required('Address')] },
line2: {},
city: { validators: [required('City')] },
country: { initial: 'gb', validators: [required('Country')] },
postcode: {
validators: [
required('Postcode'),
(value, values) => {
const rule = postcodeRules[values.country ?? 'gb']
if (!rule || !value.trim()) return null
return rule.regex.test(value.trim()) ? null : rule.message
},
],
},
phone: { validators: [phone()] },
billingSame: { initial: 'true' },
},
})
const rule = postcodeRules[form.values.country ?? 'gb']
return (
<FormShell
title="Shipping address"
description="Where the order should go."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Save address"
submittingLabel="Saving"
onReset={form.reset}
successTitle="Address saved"
width="lg"
>
<Field name="name" label="Full name" required error={form.error('name')}>
{(field) => <Input {...field} autoComplete="shipping name" {...form.field('name')} />}
</Field>
<Field name="line1" label="Address" required error={form.error('line1')}>
{(field) => (
<Input {...field} autoComplete="shipping address-line1" {...form.field('line1')} />
)}
</Field>
<Field name="line2" label="Apartment, floor, company" showOptional>
{(field) => (
<Input {...field} autoComplete="shipping address-line2" {...form.field('line2')} />
)}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="city" label="City" required error={form.error('city')}>
{(field) => (
<Input {...field} autoComplete="shipping address-level2" {...form.field('city')} />
)}
</Field>
<Field name="country" label="Country" required error={form.error('country')}>
{(field) => (
<Select
{...field}
{...form.field('country')}
options={[
{ value: 'gb', label: 'United Kingdom' },
{ value: 'ie', label: 'Ireland' },
{ value: 'de', label: 'Germany' },
{ value: 'us', label: 'United States' },
]}
/>
)}
</Field>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field
name="postcode"
label={rule?.label ?? 'Postcode'}
required
error={form.error('postcode')}
hint="Validation follows the selected country."
>
{(field) => (
<Input
{...field}
autoComplete="shipping postal-code"
className="font-mono"
{...form.field('postcode')}
/>
)}
</Field>
<Field
name="phone"
label="Phone"
showOptional
error={form.error('phone')}
hint="For delivery updates only."
>
{(field) => (
<Input {...field} type="tel" autoComplete="shipping tel" {...form.field('phone')} />
)}
</Field>
</div>
<Fieldset legend="Billing" name="billingSame">
<Checkbox
id="billing-same"
name="billingSame"
label="Billing address is the same"
checked={form.values.billingSame === 'true'}
onChange={(event) => form.setValue('billingSame', String(event.target.checked))}
/>
</Fieldset>
</FormShell>
)
}
lib/validation.ts
/**
* Validation rules.
*
* Small, composable predicates returning either an error string or `null`.
* Messages are written to be actionable — "Enter your work email" rather than
* "Invalid" — because an error message is the only part of a form a user reads
* carefully.
*/
export type Validator = (value: string, values: Record<string, string>) => string | null
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
const URL_LIKE = /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/\S*)?$/
const PHONE = /^[+]?[\d\s()-]{7,20}$/
export const required =
(label = 'This field'): Validator =>
(value) =>
value.trim().length === 0 ? `${label} is required.` : null
export const email =
(message = 'Enter a valid email address, for example name@company.com.'): Validator =>
(value) =>
value.trim().length === 0 || EMAIL.test(value.trim()) ? null : message
export const minLength =
(length: number, label = 'This field'): Validator =>
(value) =>
value.trim().length === 0 || value.trim().length >= length
? null
: `${label} must be at least ${length} characters.`
export const maxLength =
(length: number, label = 'This field'): Validator =>
(value) =>
value.length <= length ? null : `${label} must be ${length} characters or fewer.`
export const url =
(message = 'Enter a valid URL, for example example.com.'): Validator =>
(value) =>
value.trim().length === 0 || URL_LIKE.test(value.trim()) ? null : message
export const phone =
(message = 'Enter a valid phone number.'): Validator =>
(value) =>
value.trim().length === 0 || PHONE.test(value.trim()) ? null : message
export const pattern =
(regex: RegExp, message: string): Validator =>
(value) =>
value.trim().length === 0 || regex.test(value.trim()) ? null : message
export const matches =
(otherField: string, message: string): Validator =>
(value, values) =>
value === (values[otherField] ?? '') ? null : message
export const numeric =
(message = 'Enter a number.'): Validator =>
(value) =>
value.trim().length === 0 || /^-?\d+(\.\d+)?$/.test(value.trim()) ? null : message
export const checked =
(message = 'This must be accepted to continue.'): Validator =>
(value) =>
value === 'true' ? null : message
/** Password strength used across the authentication forms. */
export const strongPassword =
(message = 'Use at least 10 characters, including a number and a letter.'): Validator =>
(value) => {
if (value.length === 0) return null
const longEnough = value.length >= 10
const hasLetter = /[a-zA-Z]/.test(value)
const hasNumber = /\d/.test(value)
return longEnough && hasLetter && hasNumber ? null : message
}
export function passwordScore(value: string): { score: 0 | 1 | 2 | 3 | 4; label: string } {
if (!value) return { score: 0, label: 'Empty' }
let score = 0
if (value.length >= 10) score += 1
if (value.length >= 14) score += 1
if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score += 1
if (/\d/.test(value) && /[^\w\s]/.test(value)) score += 1
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'] as const
const clamped = Math.min(4, score) as 0 | 1 | 2 | 3 | 4
return { score: clamped, label: labels[clamped] }
}
export function runValidators(
value: string,
values: Record<string, string>,
validators: Validator[],
): string | null {
for (const validate of validators) {
const error = validate(value, values)
if (error) return error
}
return null
}
components/ui/select.tsx
import type { SelectHTMLAttributes } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/cn'
import { controlSurface } from './input'
/**
* Select
*
* A styled native `<select>`, on purpose. The native control brings free
* keyboard support, free mobile pickers and free assistive-tech semantics; the
* only thing Foundry adds is the chevron and the shared control chrome.
*
* When the interaction genuinely needs filtering or multi-select, reach for the
* Combobox instead — see `/components/combobox`.
*/
export interface SelectOption {
value: string
label: string
disabled?: boolean
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options: SelectOption[]
/** Rendered as a disabled, selected-by-default first option. */
placeholder?: string
selectSize?: 'sm' | 'md' | 'lg'
/** Groups options under `<optgroup>` labels. */
groups?: Array<{ label: string; options: SelectOption[] }>
}
const heights = {
sm: 'h-control-sm text-xs',
md: 'h-control text-sm',
lg: 'h-control-lg text-base',
} as const
export function Select({
options,
groups,
placeholder,
selectSize = 'md',
className,
...props
}: SelectProps) {
return (
<div className="relative flex items-center">
<select
className={cn(
controlSurface,
heights[selectSize],
'cursor-pointer appearance-none py-0 pr-9 pl-3',
className,
)}
defaultValue={props.value === undefined && placeholder ? '' : undefined}
{...props}
>
{placeholder ? (
<option value="" disabled>
{placeholder}
</option>
) : null}
{groups
? groups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</optgroup>
))
: options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-3 size-4 text-ink-subtle"
aria-hidden="true"
/>
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Ordered the way an address is written, with autoComplete tokens throughout — the single highest-impact change available to a checkout form. The postcode rule adapts to the selected country rather than applying one regex to the world.
- The postcode label changes with the country (Postcode, ZIP code, Postleitzahl, Eircode).
- Address line 2 is optional and named for what people actually put there.
- Phone is optional and explained: “for delivery updates only”.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- UK, US, DE and IE postcode rules
- Adaptive field label
- Billing-same checkbox
Accessibility
- Autofill
- Every field carries a section-scoped `autoComplete` token.
- Adaptive labels
- The label and error text both follow the country, so they never disagree.
- Optional clarity
- Optional fields are marked, not inferred.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsBilling details
Conditional VAT fields for business accounts, with cross-field required rules.
intermediate4 variantsCheckout wizard
Four steps with the order summary repeated on every one.
advancedFeatured5 variantsPayment method
Card entry with live formatting and a real Luhn checksum.
advancedFeatured4 variants