Skip to content

Account profile

A pre-filled settings form with a live avatar and a character budget.

Profilestarterprofilesettingsavataraccount

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 { 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.

All forms