Skip to content

Shipping address

Country-aware postcode validation and autocomplete tokens on every field.

CommerceintermediateFeaturedaddressshippingautocompletepostcodei18n

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.

All forms