Customer onboarding
Workspace creation with a live URL preview and a searchable region picker.
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 { Combobox } from '@/components/ui/combobox'
import { useDemoForm } from '@/hooks/use-demo-form'
import { pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Customer onboarding
*
* Collects only what is needed to create a usable workspace. The slug field
* previews the resulting URL live, which prevents the most common onboarding
* mistake: choosing a name and then discovering what it turned into.
*/
export default function CustomerOnboardingForm() {
const form = useDemoForm({
schema: {
workspace: { validators: [required('Workspace name')] },
slug: {
validators: [
required('Slug'),
pattern(/^[a-z0-9-]{3,32}$/, 'Use 3–32 lowercase letters, digits or hyphens.'),
],
},
region: { initial: 'eu-west', validators: [required('Region')] },
teamSize: { initial: 'small' },
},
})
return (
<FormShell
title="Set up your workspace"
description="You can change any of this later."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Create workspace"
submittingLabel="Creating workspace"
onReset={form.reset}
successTitle="Workspace created"
>
<Field name="workspace" label="Workspace name" required error={form.error('workspace')}>
{(field) => <Input {...field} placeholder="Acme Platform" {...form.field('workspace')} />}
</Field>
<Field
name="slug"
label="Workspace URL"
required
error={form.error('slug')}
hint={`Your workspace will live at foundry.example.com/${form.values.slug || 'your-slug'}`}
>
{(field) => (
<Input
{...field}
className="font-mono"
placeholder="acme-platform"
leading={<span className="text-xs">/</span>}
{...form.field('slug')}
/>
)}
</Field>
<Field
name="region"
label="Data region"
required
error={form.error('region')}
hint="Records never leave the region you choose."
>
{(field) => (
<Combobox
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
value={form.values.region}
onValueChange={(value) => form.setValue('region', value)}
options={[
{
value: 'eu-west',
label: 'EU West (Ireland)',
description: 'GDPR, data stays in the EU',
},
{
value: 'us-east',
label: 'US East (Virginia)',
description: 'Lowest latency in North America',
},
{
value: 'ap-south',
label: 'Asia Pacific (Singapore)',
description: 'Lowest latency in APAC',
},
{
value: 'sa-east',
label: 'South America (São Paulo)',
description: 'Business plan and above',
disabled: true,
},
]}
/>
)}
</Field>
<Fieldset legend="How many people will use this?" name="teamSize">
<Radio
id="size-solo"
name="teamSize"
value="solo"
label="Just me"
checked={form.values.teamSize === 'solo'}
onChange={() => form.setValue('teamSize', 'solo')}
/>
<Radio
id="size-small"
name="teamSize"
value="small"
label="2–10"
checked={form.values.teamSize === 'small'}
onChange={() => form.setValue('teamSize', 'small')}
/>
<Radio
id="size-large"
name="teamSize"
value="large"
label="More than 10"
checked={form.values.teamSize === 'large'}
onChange={() => form.setValue('teamSize', 'large')}
/>
</Fieldset>
</FormShell>
)
}
components/blocks/forms/customer-onboarding.tsx
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Combobox } from '@/components/ui/combobox'
import { useDemoForm } from '@/hooks/use-demo-form'
import { pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Customer onboarding
*
* Collects only what is needed to create a usable workspace. The slug field
* previews the resulting URL live, which prevents the most common onboarding
* mistake: choosing a name and then discovering what it turned into.
*/
export default function CustomerOnboardingForm() {
const form = useDemoForm({
schema: {
workspace: { validators: [required('Workspace name')] },
slug: {
validators: [
required('Slug'),
pattern(/^[a-z0-9-]{3,32}$/, 'Use 3–32 lowercase letters, digits or hyphens.'),
],
},
region: { initial: 'eu-west', validators: [required('Region')] },
teamSize: { initial: 'small' },
},
})
return (
<FormShell
title="Set up your workspace"
description="You can change any of this later."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Create workspace"
submittingLabel="Creating workspace"
onReset={form.reset}
successTitle="Workspace created"
>
<Field name="workspace" label="Workspace name" required error={form.error('workspace')}>
{(field) => <Input {...field} placeholder="Acme Platform" {...form.field('workspace')} />}
</Field>
<Field
name="slug"
label="Workspace URL"
required
error={form.error('slug')}
hint={`Your workspace will live at foundry.example.com/${form.values.slug || 'your-slug'}`}
>
{(field) => (
<Input
{...field}
className="font-mono"
placeholder="acme-platform"
leading={<span className="text-xs">/</span>}
{...form.field('slug')}
/>
)}
</Field>
<Field
name="region"
label="Data region"
required
error={form.error('region')}
hint="Records never leave the region you choose."
>
{(field) => (
<Combobox
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
value={form.values.region}
onValueChange={(value) => form.setValue('region', value)}
options={[
{
value: 'eu-west',
label: 'EU West (Ireland)',
description: 'GDPR, data stays in the EU',
},
{
value: 'us-east',
label: 'US East (Virginia)',
description: 'Lowest latency in North America',
},
{
value: 'ap-south',
label: 'Asia Pacific (Singapore)',
description: 'Lowest latency in APAC',
},
{
value: 'sa-east',
label: 'South America (São Paulo)',
description: 'Business plan and above',
disabled: true,
},
]}
/>
)}
</Field>
<Fieldset legend="How many people will use this?" name="teamSize">
<Radio
id="size-solo"
name="teamSize"
value="solo"
label="Just me"
checked={form.values.teamSize === 'solo'}
onChange={() => form.setValue('teamSize', 'solo')}
/>
<Radio
id="size-small"
name="teamSize"
value="small"
label="2–10"
checked={form.values.teamSize === 'small'}
onChange={() => form.setValue('teamSize', 'small')}
/>
<Radio
id="size-large"
name="teamSize"
value="large"
label="More than 10"
checked={form.values.teamSize === 'large'}
onChange={() => form.setValue('teamSize', 'large')}
/>
</Fieldset>
</FormShell>
)
}
components/ui/combobox.tsx
'use client'
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { ChevronDown, Check, Search } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'
import { controlSurface } from './input'
/**
* Combobox
*
* Implements the ARIA 1.2 combobox pattern with a filterable listbox popup:
* the text input owns `role="combobox"`, `aria-expanded` and
* `aria-activedescendant`, and the list is a real `role="listbox"` whose
* options carry `aria-selected`.
*
* Focus never leaves the input, which is what makes arrow-key browsing feel
* right and keeps the on-screen keyboard open on mobile.
*/
export interface ComboboxOption {
value: string
label: string
description?: string
disabled?: boolean
}
export interface ComboboxProps {
options: ComboboxOption[]
id?: string
name?: string
value?: string
defaultValue?: string
placeholder?: string
emptyMessage?: string
disabled?: boolean
invalid?: boolean
className?: string
'aria-describedby'?: string
onValueChange?: (value: string) => void
}
export function Combobox({
options,
id = 'combobox',
name,
value: controlledValue,
defaultValue = '',
placeholder = 'Select an option',
emptyMessage = 'No matches found.',
disabled = false,
invalid = false,
className,
onValueChange,
...aria
}: ComboboxProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const [uncontrolled, setUncontrolled] = useState(defaultValue)
const value = controlledValue ?? uncontrolled
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listId = `${id}-listbox`
const selected = options.find((option) => option.value === value)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return options
return options.filter(
(option) =>
option.label.toLowerCase().includes(q) ||
option.description?.toLowerCase().includes(q) ||
option.value.toLowerCase().includes(q),
)
}, [options, query])
useDismiss([rootRef], open, () => {
setOpen(false)
setQuery('')
})
const select = (option: ComboboxOption) => {
if (option.disabled) return
if (controlledValue === undefined) setUncontrolled(option.value)
onValueChange?.(option.value)
setOpen(false)
setQuery('')
inputRef.current?.focus()
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!open) {
setOpen(true)
setActiveIndex(0)
return
}
const direction = event.key === 'ArrowDown' ? 1 : -1
setActiveIndex((current) => {
if (filtered.length === 0) return 0
return (current + direction + filtered.length) % filtered.length
})
} else if (event.key === 'Enter') {
if (!open) return
event.preventDefault()
const option = filtered[activeIndex]
if (option) select(option)
} else if (event.key === 'Home' && open) {
event.preventDefault()
setActiveIndex(0)
} else if (event.key === 'End' && open) {
event.preventDefault()
setActiveIndex(Math.max(0, filtered.length - 1))
} else if (event.key === 'Tab') {
setOpen(false)
}
}
const activeOption = filtered[activeIndex]
return (
<div ref={rootRef} className={cn('relative', className)}>
{name ? <input type="hidden" name={name} value={value} /> : null}
<div className="relative flex items-center">
<Search
className="pointer-events-none absolute left-3 size-4 text-ink-subtle"
aria-hidden="true"
/>
<input
ref={inputRef}
id={id}
role="combobox"
type="text"
autoComplete="off"
disabled={disabled}
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
open && activeOption ? `${id}-option-${activeOption.value}` : undefined
}
aria-invalid={invalid || undefined}
aria-describedby={aria['aria-describedby']}
placeholder={placeholder}
value={open ? query : (selected?.label ?? '')}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
if (!open) setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
className={cn(
controlSurface,
'h-control cursor-default pr-9 pl-9 text-sm',
invalid && 'border-danger bg-danger-soft',
)}
/>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={disabled}
onClick={() => {
setOpen((o) => !o)
inputRef.current?.focus()
}}
className="absolute right-2 flex size-6 items-center justify-center rounded-sm text-ink-subtle"
>
<ChevronDown
className={cn('size-4 transition-transform duration-150', open && 'rotate-180')}
/>
</button>
</div>
{open ? (
<ul
id={listId}
role="listbox"
aria-label="Options"
className="absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-md border border-line bg-surface-raised p-1 shadow-md thin-scrollbar"
>
{filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-ink-muted">{emptyMessage}</li>
) : (
filtered.map((option, index) => (
<li
key={option.value}
id={`${id}-option-${option.value}`}
role="option"
aria-selected={option.value === value}
aria-disabled={option.disabled || undefined}
onMouseEnter={() => setActiveIndex(index)}
onPointerDown={(event) => {
event.preventDefault()
select(option)
}}
className={cn(
'flex cursor-pointer items-start gap-2 rounded-sm px-2.5 py-2 text-sm',
index === activeIndex && 'bg-surface-sunken',
option.disabled && 'cursor-not-allowed opacity-50',
)}
>
<Check
className={cn(
'mt-0.5 size-4 shrink-0 text-accent',
option.value === value ? 'opacity-100' : 'opacity-0',
)}
aria-hidden="true"
/>
<span className="min-w-0">
<span className="block truncate font-medium text-ink">{option.label}</span>
{option.description ? (
<span className="block truncate text-xs text-ink-muted">
{option.description}
</span>
) : null}
</span>
</li>
))
)}
</ul>
) : null}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Collects only what is needed to create a usable workspace. The slug preview prevents the most common onboarding mistake: choosing a name and then discovering what it turned into.
- The preview is in the help text, so it is announced with the field rather than sitting silently beside it.
- The unavailable region is disabled with a reason, not hidden — hiding it invites a support ticket.
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.
- Live slug preview
- Region combobox with a disabled option
- Team-size radios
Accessibility
- Live hint
- The URL preview updates inside `aria-describedby`, so it is read with the field.
- Combobox pattern
- Region uses the ARIA combobox pattern with active-descendant browsing.
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 formsOnboarding wizard
Four steps ordered to minimise abandonment, with a skippable invite step and a review screen.
advancedFeatured4 variantsCombobox
A filterable single-select implementing the ARIA 1.2 combobox pattern, with active-descendant browsing that never moves focus out of the input.
advancedFeatured5 variantsVendor onboarding
A compliance form grouped into panels, with country-aware identifiers and document upload.
advanced4 variants