Skip to content

Simple contact

Three fields with a live character budget and both minimum and maximum length rules.

ContactstarterFeaturedcontactmessageenquiry

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 { Field } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, maxLength, minLength, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Simple contact form
 *
 * Three fields, because every additional field measurably reduces completion.
 * The message field carries both a minimum and a maximum, which together are
 * what stop a contact form filling with "hi" and with pasted novels.
 */
export default function SimpleContactForm() {
  const form = useDemoForm({
    schema: {
      name: { validators: [required('Name')] },
      email: { validators: [required('Email'), email()] },
      message: {
        validators: [required('Message'), minLength(20, 'Message'), maxLength(2000, 'Message')],
      },
    },
  })

  const remaining = 2000 - (form.values.message?.length ?? 0)

  return (
    <FormShell
      title="Get in touch"
      description="We reply to everything within two working days."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Send message"
      submittingLabel="Sending"
      onReset={form.reset}
      successTitle="Message sent"
    >
      <Field name="name" label="Name" required error={form.error('name')}>
        {(field) => <Input {...field} autoComplete="name" {...form.field('name')} />}
      </Field>

      <Field name="email" label="Email" required error={form.error('email')}>
        {(field) => <Input {...field} type="email" autoComplete="email" {...form.field('email')} />}
      </Field>

      <Field
        name="message"
        label="Message"
        required
        error={form.error('message')}
        hint={`Tell us what you need. ${remaining.toLocaleString('en-US')} characters remaining.`}
      >
        {(field) => <Textarea {...field} rows={5} {...form.field('message')} />}
      </Field>
    </FormShell>
  )
}

components/blocks/forms/simple-contact.tsx

'use client'

import { Field } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, maxLength, minLength, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Simple contact form
 *
 * Three fields, because every additional field measurably reduces completion.
 * The message field carries both a minimum and a maximum, which together are
 * what stop a contact form filling with "hi" and with pasted novels.
 */
export default function SimpleContactForm() {
  const form = useDemoForm({
    schema: {
      name: { validators: [required('Name')] },
      email: { validators: [required('Email'), email()] },
      message: {
        validators: [required('Message'), minLength(20, 'Message'), maxLength(2000, 'Message')],
      },
    },
  })

  const remaining = 2000 - (form.values.message?.length ?? 0)

  return (
    <FormShell
      title="Get in touch"
      description="We reply to everything within two working days."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Send message"
      submittingLabel="Sending"
      onReset={form.reset}
      successTitle="Message sent"
    >
      <Field name="name" label="Name" required error={form.error('name')}>
        {(field) => <Input {...field} autoComplete="name" {...form.field('name')} />}
      </Field>

      <Field name="email" label="Email" required error={form.error('email')}>
        {(field) => <Input {...field} type="email" autoComplete="email" {...form.field('email')} />}
      </Field>

      <Field
        name="message"
        label="Message"
        required
        error={form.error('message')}
        hint={`Tell us what you need. ${remaining.toLocaleString('en-US')} characters remaining.`}
      >
        {(field) => <Textarea {...field} rows={5} {...form.field('message')} />}
      </Field>
    </FormShell>
  )
}

components/ui/field.tsx

import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Field
 *
 * The accessibility contract for every form control in Foundry lives here, in
 * one place, rather than being re-implemented per input.
 *
 * Ids are derived deterministically from `name` instead of `useId`, for two
 * reasons: the component stays renderable from a Server Component (no hooks),
 * and the markup is byte-identical between server and client, so a form works
 * before hydration — see `/docs/accessibility`.
 *
 * The render prop hands back the exact wiring a control needs, which makes it
 * impossible to forget `aria-describedby` on a field that has help text.
 */
export interface FieldRenderProps {
  id: string
  name: string
  'aria-describedby': string | undefined
  'aria-invalid': true | undefined
  'aria-required': true | undefined
  required: boolean
  invalid: boolean
}

export interface FieldProps {
  name: string
  label: ReactNode
  hint?: ReactNode
  /** Validation failure. Presence flips the field into its invalid state. */
  error?: string | null
  /** Confirmation message shown once a value has validated. */
  success?: string | null
  required?: boolean
  /** Marks the field "Optional" instead of marking required fields. */
  showOptional?: boolean
  /** Visually hides the label while keeping it available to screen readers. */
  hideLabel?: boolean
  className?: string
  /** Disambiguates ids when the same field name appears twice on one page. */
  idPrefix?: string
  children: (props: FieldRenderProps) => ReactNode
}

export function Field({
  name,
  label,
  hint,
  error,
  success,
  required = false,
  showOptional = false,
  hideLabel = false,
  className,
  idPrefix = 'field',
  children,
}: FieldProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const successId = success && !error ? `${id}-success` : undefined
  const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined

  return (
    <div className={cn('flex flex-col gap-1.5', className)}>
      <label
        htmlFor={id}
        className={cn(
          'flex items-baseline gap-1.5 text-sm font-medium text-ink',
          hideLabel && 'sr-only',
        )}
      >
        <span>{label}</span>
        {required && !showOptional ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
        {showOptional && !required ? (
          <span className="text-xs font-normal text-ink-subtle">Optional</span>
        ) : null}
      </label>

      {hint ? (
        <p id={hintId} className="text-xs leading-normal text-ink-muted">
          {hint}
        </p>
      ) : null}

      {children({
        id,
        name,
        'aria-describedby': describedBy,
        'aria-invalid': error ? true : undefined,
        'aria-required': required || undefined,
        required,
        invalid: Boolean(error),
      })}

      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}

      {success && !error ? (
        <p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
          <CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{success}</span>
        </p>
      ) : null}
    </div>
  )
}

/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
  legend: ReactNode
  hint?: ReactNode
  error?: string | null
  name: string
  required?: boolean
  className?: string
  children: ReactNode
  idPrefix?: string
}

export function Fieldset({
  legend,
  hint,
  error,
  name,
  required = false,
  className,
  children,
  idPrefix = 'group',
}: FieldsetProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined

  return (
    <fieldset
      className={cn('flex min-w-0 flex-col gap-2', className)}
      aria-describedby={describedBy}
      aria-invalid={error ? true : undefined}
      aria-required={required || undefined}
    >
      <legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
        <span>{legend}</span>
        {required ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
      </legend>
      {hint ? (
        <p id={hintId} className="text-xs text-ink-muted">
          {hint}
        </p>
      ) : null}
      {children}
      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}
    </fieldset>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Three fields, because every additional field measurably reduces completion. The message rules are what stop a contact form filling with “hi” and with pasted novels.

  • The remaining-character count lives in the help text, which is already announced.
  • Reply-time expectation is set in the description rather than in an auto-reply.

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.

  • Idle
  • Too short
  • Too long
  • Success

Accessibility

Budget in description
The character count is part of `aria-describedby`, so it is announced with the field.
Error focus
Submitting an invalid form moves focus to the first problem.

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