Skip to content

Split contact section

Contact details on one side, the library's own contact form on the other.

MarketingstarterFeaturedcontactformdetailscomposition

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.

import { Mail, MapPin, Phone } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import SimpleContactForm from '@/components/blocks/forms/simple-contact'

/**
 * Split contact section
 *
 * Details on one side, a working form on the other. The form is the library's
 * own SimpleContactForm rather than a re-implementation, which is the point of
 * a section library: sections compose, they do not duplicate.
 */
const details = [
  {
    icon: Mail,
    label: 'Email',
    value: 'hello@foundry.example',
    href: 'mailto:hello@foundry.example',
  },
  { icon: Phone, label: 'Phone', value: '+44 20 7946 0958', href: 'tel:+442079460958' },
  { icon: MapPin, label: 'Studio', value: '18 Ashfield Row, London EC2A 4NE' },
]

export default function SplitContactSection() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:gap-16">
          <div>
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              Talk to a person.
            </h2>
            <p className="mt-3 max-w-sm text-md text-ink-muted">
              Everything here is fictional, including the address — but the form validates for real.
            </p>

            <dl className="mt-8 flex flex-col gap-5">
              {details.map((detail) => {
                const Icon = detail.icon
                return (
                  <div key={detail.label} className="flex gap-3">
                    <Icon className="mt-0.5 size-4.5 shrink-0 text-accent" aria-hidden="true" />
                    <div>
                      <dt className="label-caps text-ink-subtle">{detail.label}</dt>
                      <dd className="mt-0.5 text-sm text-ink">
                        {detail.href ? (
                          <a
                            href={detail.href}
                            className="underline underline-offset-4 hover:text-accent"
                          >
                            {detail.value}
                          </a>
                        ) : (
                          detail.value
                        )}
                      </dd>
                    </div>
                  </div>
                )
              })}
            </dl>
          </div>

          <div className="rounded-xl border border-line bg-surface p-6 sm:p-8">
            <SimpleContactForm />
          </div>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/contact/split-form.tsx

import { Mail, MapPin, Phone } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import SimpleContactForm from '@/components/blocks/forms/simple-contact'

/**
 * Split contact section
 *
 * Details on one side, a working form on the other. The form is the library's
 * own SimpleContactForm rather than a re-implementation, which is the point of
 * a section library: sections compose, they do not duplicate.
 */
const details = [
  {
    icon: Mail,
    label: 'Email',
    value: 'hello@foundry.example',
    href: 'mailto:hello@foundry.example',
  },
  { icon: Phone, label: 'Phone', value: '+44 20 7946 0958', href: 'tel:+442079460958' },
  { icon: MapPin, label: 'Studio', value: '18 Ashfield Row, London EC2A 4NE' },
]

export default function SplitContactSection() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:gap-16">
          <div>
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              Talk to a person.
            </h2>
            <p className="mt-3 max-w-sm text-md text-ink-muted">
              Everything here is fictional, including the address — but the form validates for real.
            </p>

            <dl className="mt-8 flex flex-col gap-5">
              {details.map((detail) => {
                const Icon = detail.icon
                return (
                  <div key={detail.label} className="flex gap-3">
                    <Icon className="mt-0.5 size-4.5 shrink-0 text-accent" aria-hidden="true" />
                    <div>
                      <dt className="label-caps text-ink-subtle">{detail.label}</dt>
                      <dd className="mt-0.5 text-sm text-ink">
                        {detail.href ? (
                          <a
                            href={detail.href}
                            className="underline underline-offset-4 hover:text-accent"
                          >
                            {detail.value}
                          </a>
                        ) : (
                          detail.value
                        )}
                      </dd>
                    </div>
                  </div>
                )
              })}
            </dl>
          </div>

          <div className="rounded-xl border border-line bg-surface p-6 sm:p-8">
            <SimpleContactForm />
          </div>
        </div>
      </Container>
    </section>
  )
}

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>
  )
}

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

Usage

The form is the library's own SimpleContactForm rather than a re-implementation, which is the point of a section library: sections compose, they do not duplicate.

  • Make email and phone real links; a copyable string is not an affordance.
  • Say plainly that the details are fictional in a demo.

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.

  • Two-column at lg
  • Working form
  • Linked email and phone

Accessibility

Definition list
Contact details pair label and value programmatically.
Reused form
Validation, focus management and announcements come from the form component.

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.