Vendor onboarding
A compliance form grouped into panels, with country-aware identifiers and document upload.
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 { FileUpload } from '@/components/ui/file-upload'
import { Panel } from '@/components/ui/card'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Vendor onboarding
*
* A compliance form, grouped into panels so a long list of legal and banking
* fields reads as three short tasks rather than one wall. Panels are the right
* tool here precisely because the groups are genuinely different in kind.
*/
export default function VendorOnboardingForm() {
const form = useDemoForm({
schema: {
legalName: { validators: [required('Legal entity name')] },
tradingName: {},
registration: {
validators: [
required('Registration number'),
pattern(/^[A-Z0-9-]{6,20}$/i, 'Use 6–20 letters, digits or hyphens.'),
],
},
country: { initial: 'gb', validators: [required('Country')] },
contactName: { validators: [required('Contact name')] },
contactEmail: { validators: [required('Contact email'), email()] },
iban: {
validators: [
required('IBAN'),
pattern(
/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/i,
'Enter a valid IBAN, for example GB29NWBK60161331926819.',
),
],
},
terms: { initial: 'false' },
},
})
return (
<FormShell
title="Vendor onboarding"
description="Three short sections. Nothing is stored — this is a template demo."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Submit for review"
submittingLabel="Submitting"
onReset={form.reset}
successTitle="Submitted for review"
width="lg"
>
<Panel
title="Entity"
description="As registered with the authority in your country."
headingLevel="h4"
>
<div className="flex flex-col gap-stack">
<Field
name="legalName"
label="Legal entity name"
required
error={form.error('legalName')}
>
{(field) => <Input {...field} {...form.field('legalName')} />}
</Field>
<Field
name="tradingName"
label="Trading name"
showOptional
hint="If different from the legal name."
>
{(field) => <Input {...field} {...form.field('tradingName')} />}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field
name="registration"
label="Registration number"
required
error={form.error('registration')}
>
{(field) => (
<Input {...field} className="font-mono" {...form.field('registration')} />
)}
</Field>
<Field
name="country"
label="Country of registration"
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' },
{ value: 'sg', label: 'Singapore' },
]}
/>
)}
</Field>
</div>
</div>
</Panel>
<Panel
title="Primary contact"
description="Who we speak to about invoices."
headingLevel="h4"
>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="contactName" label="Contact name" required error={form.error('contactName')}>
{(field) => <Input {...field} autoComplete="name" {...form.field('contactName')} />}
</Field>
<Field
name="contactEmail"
label="Contact email"
required
error={form.error('contactEmail')}
>
{(field) => (
<Input {...field} type="email" autoComplete="email" {...form.field('contactEmail')} />
)}
</Field>
</div>
</Panel>
<Panel title="Payment and documents" headingLevel="h4">
<div className="flex flex-col gap-stack">
<Field
name="iban"
label="IBAN"
required
error={form.error('iban')}
hint="Demo validation only — never enter real banking details."
>
{(field) => (
<Input
{...field}
className="font-mono"
placeholder="GB29NWBK60161331926819"
{...form.field('iban')}
/>
)}
</Field>
<Field name="documents" label="Registration certificate" showOptional>
{(field) => (
<FileUpload
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
accept="application/pdf"
hint="A single PDF, up to 10 MB"
maxSizeBytes={10 * 1024 * 1024}
/>
)}
</Field>
</div>
</Panel>
<Fieldset legend="Confirmation" name="terms">
<Checkbox
id="vendor-terms"
name="terms"
label="The information above is accurate"
checked={form.values.terms === 'true'}
onChange={(event) => form.setValue('terms', String(event.target.checked))}
/>
</Fieldset>
</FormShell>
)
}
components/blocks/forms/vendor-onboarding.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 { FileUpload } from '@/components/ui/file-upload'
import { Panel } from '@/components/ui/card'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Vendor onboarding
*
* A compliance form, grouped into panels so a long list of legal and banking
* fields reads as three short tasks rather than one wall. Panels are the right
* tool here precisely because the groups are genuinely different in kind.
*/
export default function VendorOnboardingForm() {
const form = useDemoForm({
schema: {
legalName: { validators: [required('Legal entity name')] },
tradingName: {},
registration: {
validators: [
required('Registration number'),
pattern(/^[A-Z0-9-]{6,20}$/i, 'Use 6–20 letters, digits or hyphens.'),
],
},
country: { initial: 'gb', validators: [required('Country')] },
contactName: { validators: [required('Contact name')] },
contactEmail: { validators: [required('Contact email'), email()] },
iban: {
validators: [
required('IBAN'),
pattern(
/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/i,
'Enter a valid IBAN, for example GB29NWBK60161331926819.',
),
],
},
terms: { initial: 'false' },
},
})
return (
<FormShell
title="Vendor onboarding"
description="Three short sections. Nothing is stored — this is a template demo."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Submit for review"
submittingLabel="Submitting"
onReset={form.reset}
successTitle="Submitted for review"
width="lg"
>
<Panel
title="Entity"
description="As registered with the authority in your country."
headingLevel="h4"
>
<div className="flex flex-col gap-stack">
<Field
name="legalName"
label="Legal entity name"
required
error={form.error('legalName')}
>
{(field) => <Input {...field} {...form.field('legalName')} />}
</Field>
<Field
name="tradingName"
label="Trading name"
showOptional
hint="If different from the legal name."
>
{(field) => <Input {...field} {...form.field('tradingName')} />}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field
name="registration"
label="Registration number"
required
error={form.error('registration')}
>
{(field) => (
<Input {...field} className="font-mono" {...form.field('registration')} />
)}
</Field>
<Field
name="country"
label="Country of registration"
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' },
{ value: 'sg', label: 'Singapore' },
]}
/>
)}
</Field>
</div>
</div>
</Panel>
<Panel
title="Primary contact"
description="Who we speak to about invoices."
headingLevel="h4"
>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="contactName" label="Contact name" required error={form.error('contactName')}>
{(field) => <Input {...field} autoComplete="name" {...form.field('contactName')} />}
</Field>
<Field
name="contactEmail"
label="Contact email"
required
error={form.error('contactEmail')}
>
{(field) => (
<Input {...field} type="email" autoComplete="email" {...form.field('contactEmail')} />
)}
</Field>
</div>
</Panel>
<Panel title="Payment and documents" headingLevel="h4">
<div className="flex flex-col gap-stack">
<Field
name="iban"
label="IBAN"
required
error={form.error('iban')}
hint="Demo validation only — never enter real banking details."
>
{(field) => (
<Input
{...field}
className="font-mono"
placeholder="GB29NWBK60161331926819"
{...form.field('iban')}
/>
)}
</Field>
<Field name="documents" label="Registration certificate" showOptional>
{(field) => (
<FileUpload
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
accept="application/pdf"
hint="A single PDF, up to 10 MB"
maxSizeBytes={10 * 1024 * 1024}
/>
)}
</Field>
</div>
</Panel>
<Fieldset legend="Confirmation" name="terms">
<Checkbox
id="vendor-terms"
name="terms"
label="The information above is accurate"
checked={form.values.terms === 'true'}
onChange={(event) => form.setValue('terms', String(event.target.checked))}
/>
</Fieldset>
</FormShell>
)
}
components/ui/card.tsx
import type { HTMLAttributes, ReactNode, ElementType } from 'react'
import { cn } from '@/lib/cn'
/**
* Card & Panel
*
* Two containment primitives with deliberately different jobs:
*
* Card — a discrete, often interactive record in a collection.
* Panel — a titled region of a page, with an optional header action row.
*
* Keeping them separate is what stops the library from degenerating into
* "everything is a rounded box with a shadow".
*/
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
as?: ElementType
/** Forwarded when `as` renders a link. */
href?: string
/** `outline` is the default; `raised` adds elevation; `sunken` insets. */
tone?: 'outline' | 'raised' | 'sunken' | 'ghost' | 'accent'
/** Adds hover affordance. Only use when the whole card is a link/button. */
interactive?: boolean
padding?: 'none' | 'sm' | 'md' | 'lg'
}
const cardTones = {
outline: 'bg-surface border border-line',
raised: 'bg-surface-raised border border-line shadow-sm',
sunken: 'bg-surface-sunken border border-line-subtle',
ghost: 'bg-transparent border border-transparent',
accent: 'bg-accent-soft border border-accent-line',
} as const
const cardPadding = {
none: 'p-0',
sm: 'p-3',
md: 'p-card',
lg: 'p-6 sm:p-8',
} as const
export function Card({
as: Tag = 'div',
tone = 'outline',
interactive = false,
padding = 'md',
className,
children,
...props
}: CardProps) {
return (
<Tag
className={cn(
'rounded-lg',
cardTones[tone],
cardPadding[padding],
interactive &&
'transition-[border-color,box-shadow,background-color] duration-150 ease-standard hover:border-line-strong hover:shadow-sm',
className,
)}
{...props}
>
{children}
</Tag>
)
}
export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('flex items-start justify-between gap-4', className)} {...props}>
{children}
</div>
)
}
export function CardTitle({
as: Tag = 'h3',
className,
children,
...props
}: HTMLAttributes<HTMLHeadingElement> & { as?: ElementType }) {
return (
<Tag className={cn('text-md leading-snug font-semibold text-ink-strong', className)} {...props}>
{children}
</Tag>
)
}
export function CardDescription({
className,
children,
...props
}: HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn('text-sm leading-normal text-ink-muted', className)} {...props}>
{children}
</p>
)
}
export function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'mt-4 flex flex-wrap items-center gap-3 border-t border-line-subtle pt-4',
className,
)}
{...props}
>
{children}
</div>
)
}
export interface PanelProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
title: ReactNode
description?: ReactNode
/** Rendered on the right of the panel header. */
action?: ReactNode
/** Removes body padding — for tables and lists that manage their own. */
flush?: boolean
as?: ElementType
headingLevel?: 'h2' | 'h3' | 'h4'
}
export function Panel({
title,
description,
action,
flush = false,
as: Tag = 'section',
headingLevel: Heading = 'h3',
className,
children,
...props
}: PanelProps) {
return (
<Tag
className={cn('overflow-hidden rounded-lg border border-line bg-surface', className)}
{...props}
>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-line-subtle bg-surface-sunken px-4 py-3">
<div className="min-w-0">
<Heading className="text-sm font-semibold text-ink-strong">{title}</Heading>
{description ? <p className="mt-0.5 text-xs text-ink-muted">{description}</p> : null}
</div>
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
</div>
<div className={cn(flush ? '' : 'p-card')}>{children}</div>
</Tag>
)
}
components/ui/file-upload.tsx
'use client'
import { useRef, useState, type DragEvent } from 'react'
import { UploadCloud, File as FileIcon, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { Button } from './button'
/**
* FileUpload
*
* A drop zone that is genuinely operable from the keyboard: the visible
* surface is a `<button>` that opens the native picker, so Space and Enter
* work, and the file input itself stays in the DOM for form submission.
*
* Nothing is uploaded — this is a template demo. Selected files are listed
* locally and can be removed.
*/
export interface FileUploadProps {
name?: string
id?: string
accept?: string
multiple?: boolean
disabled?: boolean
/** Human-readable constraint, e.g. "PNG or PDF, up to 10 MB". */
hint?: string
maxSizeBytes?: number
className?: string
'aria-describedby'?: string
onFilesChange?: (files: File[]) => void
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function FileUpload({
name = 'files',
id = 'file-upload',
accept,
multiple = false,
disabled = false,
hint,
maxSizeBytes,
className,
onFilesChange,
...aria
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<File[]>([])
const [dragging, setDragging] = useState(false)
const [error, setError] = useState<string | null>(null)
const accept_ = (incoming: FileList | null) => {
if (!incoming) return
const next = Array.from(incoming)
const oversize = maxSizeBytes ? next.find((file) => file.size > maxSizeBytes) : undefined
if (oversize) {
setError(`${oversize.name} is larger than ${formatBytes(maxSizeBytes ?? 0)}.`)
return
}
setError(null)
const merged = multiple ? [...files, ...next] : next.slice(0, 1)
setFiles(merged)
onFilesChange?.(merged)
}
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
setDragging(false)
if (disabled) return
accept_(event.dataTransfer.files)
}
const remove = (index: number) => {
const next = files.filter((_, i) => i !== index)
setFiles(next)
onFilesChange?.(next)
if (inputRef.current) inputRef.current.value = ''
}
return (
<div className={cn('flex flex-col gap-3', className)}>
<div
onDragOver={(event) => {
event.preventDefault()
if (!disabled) setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={cn(
'rounded-md border border-dashed transition-colors duration-150 ease-standard',
dragging ? 'border-accent bg-accent-soft' : 'border-line-strong bg-surface-sunken',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
aria-describedby={aria['aria-describedby']}
className="flex w-full flex-col items-center gap-2 px-4 py-8 text-center disabled:cursor-not-allowed"
>
<UploadCloud className="size-6 text-ink-subtle" aria-hidden="true" />
<span className="text-sm font-medium text-ink">
Drop {multiple ? 'files' : 'a file'} here, or{' '}
<span className="text-accent underline underline-offset-4">browse</span>
</span>
{hint ? <span className="text-xs text-ink-muted">{hint}</span> : null}
</button>
<input
ref={inputRef}
id={id}
name={name}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="sr-only"
onChange={(event) => accept_(event.target.files)}
/>
</div>
{error ? (
<p role="alert" className="text-xs font-medium text-danger">
{error}
</p>
) : null}
{files.length > 0 ? (
<ul className="flex flex-col gap-1.5" aria-label="Selected files">
{files.map((file, index) => (
<li
key={`${file.name}-${index}`}
className="flex items-center gap-2.5 rounded-md border border-line bg-surface px-3 py-2 text-sm"
>
<FileIcon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-ink">{file.name}</span>
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
{formatBytes(file.size)}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={() => remove(index)}
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" aria-hidden="true" />
</Button>
</li>
))}
</ul>
) : null}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
A long list of legal and banking fields becomes three short tasks when grouped into panels. Panels are the right tool precisely because the groups are genuinely different in kind.
- Never ask for real banking details in a demo — the copy says so explicitly.
- Registration-number format varies by country, so the rule is deliberately permissive within a shape.
- Panels carry h4 headings so the page outline stays correct inside a form.
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.
- Entity panel
- Contact panel
- Payment panel
- IBAN validation
Accessibility
- Panel headings
- Each group is a labelled region with a real heading.
- Monospace identifiers
- Reference numbers use a monospace face so digits are distinguishable.
- Upload labelling
- The document field is a labelled Field wrapping the upload primitive.
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 formsCustomer onboarding
Workspace creation with a live URL preview and a searchable region picker.
starter3 variantsBilling details
Conditional VAT fields for business accounts, with cross-field required rules.
intermediate4 variantsPanel
A titled region with a header action row and an optional flush body for tables and lists.
starter3 variants