Skip to content

Billing details

Conditional VAT fields for business accounts, with cross-field required rules.

Commerceintermediatebillinginvoicevatconditional

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 { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { Alert } from '@/components/ui/alert'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Billing details
 *
 * The VAT field appears only for business accounts, which is the correct
 * behaviour: showing an irrelevant field and hoping people skip it is how
 * forms accumulate abandoned drafts.
 */
export default function BillingDetailsForm() {
  const form = useDemoForm({
    schema: {
      accountType: { initial: 'business' },
      companyName: {
        validators: [
          (value, values) =>
            values.accountType === 'business' && !value.trim()
              ? 'Company name is required for a business account.'
              : null,
        ],
      },
      vat: {
        validators: [
          (value, values) => {
            if (values.accountType !== 'business' || !value.trim()) return null
            return /^[A-Z]{2}[A-Z0-9]{6,14}$/i.test(value.trim())
              ? null
              : 'Enter a valid VAT number, for example GB123456789.'
          },
        ],
      },
      billingEmail: { validators: [required('Billing email'), email()] },
      poNumber: {
        validators: [pattern(/^[\w-]{0,32}$/, 'Use up to 32 letters, digits or hyphens.')],
      },
      currency: { initial: 'usd' },
    },
  })

  const isBusiness = form.values.accountType === 'business'

  return (
    <FormShell
      title="Billing details"
      description="These appear on every invoice."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Save billing details"
      submittingLabel="Saving"
      onReset={form.reset}
      successTitle="Billing details saved"
      width="lg"
    >
      <Fieldset legend="Account type" name="accountType">
        <Radio
          id="account-business"
          name="accountType"
          value="business"
          appearance="card"
          label="Business"
          description="Invoices include your company name and VAT number."
          checked={isBusiness}
          onChange={() => form.setValue('accountType', 'business')}
        />
        <Radio
          id="account-individual"
          name="accountType"
          value="individual"
          appearance="card"
          label="Individual"
          description="Invoices are issued to your name."
          checked={!isBusiness}
          onChange={() => form.setValue('accountType', 'individual')}
        />
      </Fieldset>

      {isBusiness ? (
        <div className="grid gap-stack sm:grid-cols-2">
          <Field name="companyName" label="Company name" required error={form.error('companyName')}>
            {(field) => (
              <Input {...field} autoComplete="organization" {...form.field('companyName')} />
            )}
          </Field>
          <Field
            name="vat"
            label="VAT number"
            showOptional
            error={form.error('vat')}
            hint="Leave blank if not VAT registered."
          >
            {(field) => (
              <Input
                {...field}
                className="font-mono"
                placeholder="GB123456789"
                {...form.field('vat')}
              />
            )}
          </Field>
        </div>
      ) : null}

      <div className="grid gap-stack sm:grid-cols-2">
        <Field
          name="billingEmail"
          label="Billing email"
          required
          error={form.error('billingEmail')}
          hint="Invoices are sent here."
        >
          {(field) => (
            <Input {...field} type="email" autoComplete="email" {...form.field('billingEmail')} />
          )}
        </Field>
        <Field name="currency" label="Currency">
          {(field) => (
            <Select
              {...field}
              {...form.field('currency')}
              options={[
                { value: 'usd', label: 'USD — US Dollar' },
                { value: 'eur', label: 'EUR — Euro' },
                { value: 'gbp', label: 'GBP — Pound Sterling' },
              ]}
            />
          )}
        </Field>
      </div>

      <Field
        name="poNumber"
        label="Purchase order number"
        showOptional
        error={form.error('poNumber')}
      >
        {(field) => <Input {...field} className="font-mono" {...form.field('poNumber')} />}
      </Field>

      <Alert tone="neutral">Currency cannot be changed once an invoice has been issued.</Alert>
    </FormShell>
  )
}

components/blocks/forms/billing-details.tsx

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { Alert } from '@/components/ui/alert'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Billing details
 *
 * The VAT field appears only for business accounts, which is the correct
 * behaviour: showing an irrelevant field and hoping people skip it is how
 * forms accumulate abandoned drafts.
 */
export default function BillingDetailsForm() {
  const form = useDemoForm({
    schema: {
      accountType: { initial: 'business' },
      companyName: {
        validators: [
          (value, values) =>
            values.accountType === 'business' && !value.trim()
              ? 'Company name is required for a business account.'
              : null,
        ],
      },
      vat: {
        validators: [
          (value, values) => {
            if (values.accountType !== 'business' || !value.trim()) return null
            return /^[A-Z]{2}[A-Z0-9]{6,14}$/i.test(value.trim())
              ? null
              : 'Enter a valid VAT number, for example GB123456789.'
          },
        ],
      },
      billingEmail: { validators: [required('Billing email'), email()] },
      poNumber: {
        validators: [pattern(/^[\w-]{0,32}$/, 'Use up to 32 letters, digits or hyphens.')],
      },
      currency: { initial: 'usd' },
    },
  })

  const isBusiness = form.values.accountType === 'business'

  return (
    <FormShell
      title="Billing details"
      description="These appear on every invoice."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Save billing details"
      submittingLabel="Saving"
      onReset={form.reset}
      successTitle="Billing details saved"
      width="lg"
    >
      <Fieldset legend="Account type" name="accountType">
        <Radio
          id="account-business"
          name="accountType"
          value="business"
          appearance="card"
          label="Business"
          description="Invoices include your company name and VAT number."
          checked={isBusiness}
          onChange={() => form.setValue('accountType', 'business')}
        />
        <Radio
          id="account-individual"
          name="accountType"
          value="individual"
          appearance="card"
          label="Individual"
          description="Invoices are issued to your name."
          checked={!isBusiness}
          onChange={() => form.setValue('accountType', 'individual')}
        />
      </Fieldset>

      {isBusiness ? (
        <div className="grid gap-stack sm:grid-cols-2">
          <Field name="companyName" label="Company name" required error={form.error('companyName')}>
            {(field) => (
              <Input {...field} autoComplete="organization" {...form.field('companyName')} />
            )}
          </Field>
          <Field
            name="vat"
            label="VAT number"
            showOptional
            error={form.error('vat')}
            hint="Leave blank if not VAT registered."
          >
            {(field) => (
              <Input
                {...field}
                className="font-mono"
                placeholder="GB123456789"
                {...form.field('vat')}
              />
            )}
          </Field>
        </div>
      ) : null}

      <div className="grid gap-stack sm:grid-cols-2">
        <Field
          name="billingEmail"
          label="Billing email"
          required
          error={form.error('billingEmail')}
          hint="Invoices are sent here."
        >
          {(field) => (
            <Input {...field} type="email" autoComplete="email" {...form.field('billingEmail')} />
          )}
        </Field>
        <Field name="currency" label="Currency">
          {(field) => (
            <Select
              {...field}
              {...form.field('currency')}
              options={[
                { value: 'usd', label: 'USD — US Dollar' },
                { value: 'eur', label: 'EUR — Euro' },
                { value: 'gbp', label: 'GBP — Pound Sterling' },
              ]}
            />
          )}
        </Field>
      </div>

      <Field
        name="poNumber"
        label="Purchase order number"
        showOptional
        error={form.error('poNumber')}
      >
        {(field) => <Input {...field} className="font-mono" {...form.field('poNumber')} />}
      </Field>

      <Alert tone="neutral">Currency cannot be changed once an invoice has been issued.</Alert>
    </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/choice.tsx

import type { InputHTMLAttributes, ReactNode } from 'react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Checkbox / Radio / Switch
 *
 * All three keep a real, focusable native input in the DOM and paint the
 * visible control with a sibling element. That keeps `aria-checked`, form
 * submission, `:checked`, `:disabled` and keyboard behaviour native, while
 * still allowing a token-driven appearance.
 *
 * The native input is positioned over the visual control rather than hidden
 * with `display:none`, so the tap target is the full 44px row on touch.
 */

const controlBox = cn(
  'pointer-events-none flex shrink-0 items-center justify-center border transition-colors duration-150 ease-standard',
  'border-line-strong bg-surface text-accent-ink',
  'peer-hover:border-accent',
  'peer-checked:border-accent peer-checked:bg-accent',
  'peer-disabled:opacity-50',
  'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
  'peer-aria-[invalid=true]:border-danger',
  // The tick/dot is a *descendant* of this box, not a sibling of the input, so
  // the peer variant has to reach through with a child selector.
  '[&>*]:opacity-0 peer-checked:[&>*]:opacity-100',
)

const nativeInput = cn(
  'peer absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed',
)

export interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
  label: ReactNode
  description?: ReactNode
  /** `card` turns the whole row into a bordered, selectable surface. */
  appearance?: 'inline' | 'card'
}

export function Checkbox({
  label,
  description,
  appearance = 'inline',
  className,
  id,
  indeterminate,
  ...props
}: ChoiceProps & { indeterminate?: boolean }) {
  return (
    <label
      className={cn(
        'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
        appearance === 'card' &&
          'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        appearance === 'inline' && 'py-1.5',
        className,
      )}
      htmlFor={id}
    >
      <span className="relative flex size-4.5 shrink-0 items-center justify-center">
        <input
          type="checkbox"
          id={id}
          className={nativeInput}
          aria-checked={indeterminate ? 'mixed' : undefined}
          {...props}
        />
        <span className={cn(controlBox, 'size-4.5 rounded-sm')} aria-hidden="true">
          {indeterminate ? (
            <Minus className="size-3" strokeWidth={3} />
          ) : (
            <Check className="size-3" strokeWidth={3} />
          )}
        </span>
      </span>
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
    </label>
  )
}

export function Radio({
  label,
  description,
  appearance = 'inline',
  className,
  id,
  ...props
}: ChoiceProps) {
  return (
    <label
      className={cn(
        'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
        appearance === 'card' &&
          'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        appearance === 'inline' && 'py-1.5',
        className,
      )}
      htmlFor={id}
    >
      <span className="relative flex size-4.5 shrink-0 items-center justify-center">
        <input type="radio" id={id} className={nativeInput} {...props} />
        <span className={cn(controlBox, 'size-4.5 rounded-full')} aria-hidden="true">
          <span className="size-1.5 rounded-full bg-current" />
        </span>
      </span>
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
    </label>
  )
}

export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
  label: ReactNode
  description?: ReactNode
  /** Places the switch on the trailing edge — the settings-row convention. */
  align?: 'leading' | 'trailing'
}

export function Switch({
  label,
  description,
  align = 'leading',
  className,
  id,
  ...props
}: SwitchProps) {
  const control = (
    <span className="relative inline-flex h-5 w-9 shrink-0 items-center">
      <input type="checkbox" role="switch" id={id} className={nativeInput} {...props} />
      <span
        className={cn(
          'pointer-events-none h-5 w-9 rounded-full border border-line-strong bg-surface-sunken transition-colors duration-150 ease-standard',
          'peer-checked:border-accent peer-checked:bg-accent',
          'peer-disabled:opacity-50',
          'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
        )}
        aria-hidden="true"
      />
      <span
        className={cn(
          'pointer-events-none absolute left-0.5 size-4 rounded-full bg-surface shadow-sm transition-transform duration-150 ease-standard',
          'border border-line peer-checked:translate-x-4 peer-checked:border-transparent',
        )}
        aria-hidden="true"
      />
    </span>
  )

  return (
    <label
      className={cn(
        'flex min-h-9 cursor-pointer items-start gap-3 py-1.5 text-sm has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        align === 'trailing' && 'justify-between',
        className,
      )}
      htmlFor={id}
    >
      {align === 'leading' ? control : null}
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
      {align === 'trailing' ? control : null}
    </label>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

The VAT field appears only for business accounts. Showing an irrelevant field and hoping people skip it is how forms accumulate abandoned drafts.

  • The company-name rule is conditional on account type, expressed as a validator that reads all values.
  • Currency immutability is stated before submission, not discovered at the first invoice.

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.

  • Business and individual modes
  • Conditional VAT
  • Purchase order
  • Currency lock notice

Accessibility

Conditional fields
Fields are removed from the DOM, not merely hidden, so they leave the tab order.
Card selection
Account type uses card radios, since each option needs an explanation.

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