Account profile
A pre-filled settings form with a live avatar and a character budget.
Live preview
Source
This exact file renders the preview above.
'use client'
import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Field } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { useDemoForm } from '@/hooks/use-demo-form'
import { maxLength, pattern, required, url } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Account profile
*
* A settings form pre-filled with existing values, which changes the
* interaction: the user is editing, not entering. The avatar updates live from
* the name field, so the effect of a change is visible before saving.
*/
export default function AccountProfileForm() {
const form = useDemoForm({
schema: {
name: { initial: 'Priya Raman', validators: [required('Name')] },
handle: {
initial: 'priya',
validators: [
required('Username'),
pattern(
/^[a-z0-9_-]{3,24}$/,
'Use 3–24 lowercase letters, digits, hyphens or underscores.',
),
],
},
title: { initial: 'Principal Engineer' },
website: { initial: 'northwind.example', validators: [url()] },
timezone: { initial: 'europe-london' },
bio: {
initial:
'Design systems, accessibility, and the parts of performance that turn out to be architecture problems.',
validators: [maxLength(280, 'Bio')],
},
},
})
const remaining = 280 - (form.values.bio?.length ?? 0)
return (
<FormShell
title="Profile"
description="Visible to everyone in your workspace."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Save profile"
submittingLabel="Saving"
onReset={form.reset}
successTitle="Profile saved"
width="lg"
>
<div className="flex flex-wrap items-center gap-4">
<Avatar name={form.values.name || 'Unnamed'} size="xl" decorative />
<div>
<p className="text-sm font-medium text-ink">Avatar</p>
<p className="mt-0.5 text-xs text-ink-muted">
Generated from your name. It updates as you type.
</p>
<Button type="button" variant="outline" size="sm" className="mt-2">
Upload an image
</Button>
</div>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="name" label="Display name" required error={form.error('name')}>
{(field) => <Input {...field} autoComplete="name" {...form.field('name')} />}
</Field>
<Field
name="handle"
label="Username"
required
error={form.error('handle')}
hint="Used in mentions and URLs."
>
{(field) => (
<Input
{...field}
className="font-mono"
leading={<span className="text-xs">@</span>}
{...form.field('handle')}
/>
)}
</Field>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="title" label="Job title" showOptional>
{(field) => (
<Input {...field} autoComplete="organization-title" {...form.field('title')} />
)}
</Field>
<Field name="website" label="Website" showOptional error={form.error('website')}>
{(field) => <Input {...field} inputMode="url" {...form.field('website')} />}
</Field>
</div>
<Field name="timezone" label="Timezone" hint="Used for scheduled reports and digests.">
{(field) => (
<Select
{...field}
{...form.field('timezone')}
options={[
{ value: 'europe-london', label: 'Europe / London' },
{ value: 'europe-berlin', label: 'Europe / Berlin' },
{ value: 'america-new_york', label: 'America / New York' },
{ value: 'asia-kolkata', label: 'Asia / Kolkata' },
{ value: 'asia-tokyo', label: 'Asia / Tokyo' },
]}
/>
)}
</Field>
<Field
name="bio"
label="Bio"
showOptional
error={form.error('bio')}
hint={`${remaining} characters remaining.`}
>
{(field) => <Textarea {...field} rows={4} {...form.field('bio')} />}
</Field>
</FormShell>
)
}
components/blocks/forms/account-profile.tsx
'use client'
import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Field } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { useDemoForm } from '@/hooks/use-demo-form'
import { maxLength, pattern, required, url } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Account profile
*
* A settings form pre-filled with existing values, which changes the
* interaction: the user is editing, not entering. The avatar updates live from
* the name field, so the effect of a change is visible before saving.
*/
export default function AccountProfileForm() {
const form = useDemoForm({
schema: {
name: { initial: 'Priya Raman', validators: [required('Name')] },
handle: {
initial: 'priya',
validators: [
required('Username'),
pattern(
/^[a-z0-9_-]{3,24}$/,
'Use 3–24 lowercase letters, digits, hyphens or underscores.',
),
],
},
title: { initial: 'Principal Engineer' },
website: { initial: 'northwind.example', validators: [url()] },
timezone: { initial: 'europe-london' },
bio: {
initial:
'Design systems, accessibility, and the parts of performance that turn out to be architecture problems.',
validators: [maxLength(280, 'Bio')],
},
},
})
const remaining = 280 - (form.values.bio?.length ?? 0)
return (
<FormShell
title="Profile"
description="Visible to everyone in your workspace."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Save profile"
submittingLabel="Saving"
onReset={form.reset}
successTitle="Profile saved"
width="lg"
>
<div className="flex flex-wrap items-center gap-4">
<Avatar name={form.values.name || 'Unnamed'} size="xl" decorative />
<div>
<p className="text-sm font-medium text-ink">Avatar</p>
<p className="mt-0.5 text-xs text-ink-muted">
Generated from your name. It updates as you type.
</p>
<Button type="button" variant="outline" size="sm" className="mt-2">
Upload an image
</Button>
</div>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="name" label="Display name" required error={form.error('name')}>
{(field) => <Input {...field} autoComplete="name" {...form.field('name')} />}
</Field>
<Field
name="handle"
label="Username"
required
error={form.error('handle')}
hint="Used in mentions and URLs."
>
{(field) => (
<Input
{...field}
className="font-mono"
leading={<span className="text-xs">@</span>}
{...form.field('handle')}
/>
)}
</Field>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="title" label="Job title" showOptional>
{(field) => (
<Input {...field} autoComplete="organization-title" {...form.field('title')} />
)}
</Field>
<Field name="website" label="Website" showOptional error={form.error('website')}>
{(field) => <Input {...field} inputMode="url" {...form.field('website')} />}
</Field>
</div>
<Field name="timezone" label="Timezone" hint="Used for scheduled reports and digests.">
{(field) => (
<Select
{...field}
{...form.field('timezone')}
options={[
{ value: 'europe-london', label: 'Europe / London' },
{ value: 'europe-berlin', label: 'Europe / Berlin' },
{ value: 'america-new_york', label: 'America / New York' },
{ value: 'asia-kolkata', label: 'Asia / Kolkata' },
{ value: 'asia-tokyo', label: 'Asia / Tokyo' },
]}
/>
)}
</Field>
<Field
name="bio"
label="Bio"
showOptional
error={form.error('bio')}
hint={`${remaining} characters remaining.`}
>
{(field) => <Textarea {...field} rows={4} {...form.field('bio')} />}
</Field>
</FormShell>
)
}
components/ui/avatar.tsx
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
import { initials as toInitials } from '@/lib/format'
/**
* Avatar / AvatarGroup
*
* Foundry ships no photography, so avatars render deterministic initials on a
* tinted surface. The tint is derived from the name's character codes, which
* keeps the same person the same colour on every page without a colour field
* in the data.
*
* A decorative avatar next to a visible name is `aria-hidden`; a standalone
* one exposes the name as its label.
*/
export interface AvatarProps {
name: string
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/** Suppresses the accessible name when the name is already on screen. */
decorative?: boolean
/** Small badge anchored bottom-right, e.g. a presence dot. */
indicator?: ReactNode
shape?: 'circle' | 'square'
className?: string
}
const sizes = {
xs: 'size-5 text-2xs',
sm: 'size-7 text-2xs',
md: 'size-9 text-xs',
lg: 'size-12 text-sm',
xl: 'size-16 text-lg',
} as const
const tints = [
'bg-accent-soft text-accent-soft-ink',
'bg-success-soft text-success',
'bg-warning-soft text-warning',
'bg-info-soft text-info',
'bg-danger-soft text-danger',
'bg-surface-sunken text-ink-muted',
] as const
function tintFor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) % 997
return tints[hash % tints.length] ?? tints[0]
}
export function Avatar({
name,
size = 'md',
decorative = false,
indicator,
shape = 'circle',
className,
}: AvatarProps) {
return (
<span className={cn('relative inline-flex shrink-0', className)}>
<span
role={decorative ? undefined : 'img'}
aria-label={decorative ? undefined : name}
aria-hidden={decorative || undefined}
className={cn(
'inline-flex items-center justify-center border border-line font-semibold select-none',
shape === 'circle' ? 'rounded-full' : 'rounded-md',
sizes[size],
tintFor(name),
)}
>
{toInitials(name)}
</span>
{indicator ? <span className="absolute -right-0.5 -bottom-0.5">{indicator}</span> : null}
</span>
)
}
export interface AvatarGroupProps {
names: string[]
size?: AvatarProps['size']
/** Names beyond this count collapse into a "+n" chip. */
max?: number
className?: string
label?: string
}
export function AvatarGroup({ names, size = 'sm', max = 4, className, label }: AvatarGroupProps) {
const visible = names.slice(0, max)
const overflow = names.length - visible.length
return (
<span
className={cn('flex items-center', className)}
role="group"
aria-label={label ?? `${names.length} people`}
>
{visible.map((name) => (
<span
key={name}
className="-ml-2 first:ml-0 ring-2 ring-[var(--color-surface)] rounded-full"
>
<Avatar name={name} size={size} decorative />
</span>
))}
{overflow > 0 ? (
<span
className={cn(
'-ml-2 inline-flex items-center justify-center rounded-full border border-line bg-surface-sunken font-semibold text-ink-muted ring-2 ring-[var(--color-surface)]',
sizes[size],
)}
>
+{overflow}
</span>
) : null}
<span className="sr-only">{names.join(', ')}</span>
</span>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Pre-filled values change the interaction: the user is editing, not entering. The avatar updates live from the name field, so the effect of a change is visible before saving.
- Reset restores the original values, not empty ones — that is what Reset means on a settings form.
- The username rule is stated in help text and enforced with a matching pattern.
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.
- Pre-filled values
- Live avatar
- Username rules
- Bio budget
Accessibility
- Live preview
- The avatar is decorative; the name field remains the source of truth.
- Budget in hint
- Remaining characters are part of the field description.
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 formsPreferences
Immediate switches alongside saved selects, with the distinction made explicit.
starter3 variantsSecurity settings
Password change, two-factor management and a session list with a guarded bulk action.
advancedFeatured4 variantsAvatar
Deterministic initials on a name-derived tint, in five sizes, with an optional indicator.
starter4 variants