Billing details
Conditional VAT fields for business accounts, with cross-field required rules.
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 { 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.
Related
All formsShipping address
Country-aware postcode validation and autocomplete tokens on every field.
intermediateFeatured3 variantsPayment method
Card entry with live formatting and a real Luhn checksum.
advancedFeatured4 variantsCheckout wizard
Four steps with the order summary repeated on every one.
advancedFeatured5 variants