Skip to content

Newsletter footer

A validated subscribe form as the footer’s primary content, with an honest success message.

Footersintermediatefooternewsletterformvalidation

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 { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { BrandMark } from '@/components/library/brand'
import { resourceLinks } from '@/content/nav-demo'

/**
 * Newsletter footer
 *
 * A subscribe form as the footer's primary content. Validation runs locally
 * and the success message states plainly that nothing was sent — a demo that
 * pretends to have subscribed you is a demo that lies.
 */
export default function NewsletterFooter() {
  const [email, setEmail] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [done, setDone] = useState(false)

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const value = email.trim()
    if (!value) {
      setError('Enter an email address.')
      return
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
      setError('Enter a valid email address, for example name@company.com.')
      return
    }
    setError(null)
    setDone(true)
  }

  return (
    <footer className="border-t border-line bg-canvas">
      <Container className="py-14">
        <div className="grid gap-10 lg:grid-cols-[1.2fr_1fr] lg:gap-16">
          <div>
            <div className="flex items-center gap-2 text-ink-strong">
              <BrandMark className="size-5 text-accent" />
              <span className="display-type text-md font-semibold">Foundry</span>
            </div>
            <h2 className="display-type mt-4 text-xl font-semibold text-ink-strong">
              One email when something meaningful ships.
            </h2>
            <p className="mt-2 max-w-md text-sm text-ink-muted">
              New components, breaking changes and the occasional note on why a decision went the
              way it did. No cadence, no marketing.
            </p>
          </div>

          <div>
            {done ? (
              <Alert tone="success" title="Subscribed">
                This is a template demo. No submission was sent.
              </Alert>
            ) : (
              <form onSubmit={submit} noValidate className="flex flex-col gap-3">
                <Field
                  name="newsletter-email"
                  label="Email address"
                  hint="Used only for this demo — nothing leaves your browser."
                  error={error}
                >
                  {(field) => (
                    <Input
                      {...field}
                      type="email"
                      autoComplete="email"
                      placeholder="you@company.com"
                      value={email}
                      onChange={(event) => {
                        setEmail(event.target.value)
                        if (error) setError(null)
                      }}
                    />
                  )}
                </Field>
                <Button type="submit" className="self-start">
                  Subscribe
                </Button>
              </form>
            )}
          </div>
        </div>

        <div className="mt-12 flex flex-col gap-4 border-t border-line pt-6 sm:flex-row sm:items-center sm:justify-between">
          <nav aria-label="Footer">
            <ul className="flex flex-wrap gap-x-5 gap-y-2">
              {resourceLinks.map((link) => (
                <li key={link.href}>
                  <Link
                    href={link.href}
                    className="text-sm text-ink-muted transition-colors hover:text-accent"
                  >
                    {link.label}
                  </Link>
                </li>
              ))}
            </ul>
          </nav>
          <p className="text-xs text-ink-subtle">Foundry 1.0</p>
        </div>
      </Container>
    </footer>
  )
}

components/blocks/footers/newsletter.tsx

'use client'

import { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { BrandMark } from '@/components/library/brand'
import { resourceLinks } from '@/content/nav-demo'

/**
 * Newsletter footer
 *
 * A subscribe form as the footer's primary content. Validation runs locally
 * and the success message states plainly that nothing was sent — a demo that
 * pretends to have subscribed you is a demo that lies.
 */
export default function NewsletterFooter() {
  const [email, setEmail] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [done, setDone] = useState(false)

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const value = email.trim()
    if (!value) {
      setError('Enter an email address.')
      return
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
      setError('Enter a valid email address, for example name@company.com.')
      return
    }
    setError(null)
    setDone(true)
  }

  return (
    <footer className="border-t border-line bg-canvas">
      <Container className="py-14">
        <div className="grid gap-10 lg:grid-cols-[1.2fr_1fr] lg:gap-16">
          <div>
            <div className="flex items-center gap-2 text-ink-strong">
              <BrandMark className="size-5 text-accent" />
              <span className="display-type text-md font-semibold">Foundry</span>
            </div>
            <h2 className="display-type mt-4 text-xl font-semibold text-ink-strong">
              One email when something meaningful ships.
            </h2>
            <p className="mt-2 max-w-md text-sm text-ink-muted">
              New components, breaking changes and the occasional note on why a decision went the
              way it did. No cadence, no marketing.
            </p>
          </div>

          <div>
            {done ? (
              <Alert tone="success" title="Subscribed">
                This is a template demo. No submission was sent.
              </Alert>
            ) : (
              <form onSubmit={submit} noValidate className="flex flex-col gap-3">
                <Field
                  name="newsletter-email"
                  label="Email address"
                  hint="Used only for this demo — nothing leaves your browser."
                  error={error}
                >
                  {(field) => (
                    <Input
                      {...field}
                      type="email"
                      autoComplete="email"
                      placeholder="you@company.com"
                      value={email}
                      onChange={(event) => {
                        setEmail(event.target.value)
                        if (error) setError(null)
                      }}
                    />
                  )}
                </Field>
                <Button type="submit" className="self-start">
                  Subscribe
                </Button>
              </form>
            )}
          </div>
        </div>

        <div className="mt-12 flex flex-col gap-4 border-t border-line pt-6 sm:flex-row sm:items-center sm:justify-between">
          <nav aria-label="Footer">
            <ul className="flex flex-wrap gap-x-5 gap-y-2">
              {resourceLinks.map((link) => (
                <li key={link.href}>
                  <Link
                    href={link.href}
                    className="text-sm text-ink-muted transition-colors hover:text-accent"
                  >
                    {link.label}
                  </Link>
                </li>
              ))}
            </ul>
          </nav>
          <p className="text-xs text-ink-subtle">Foundry 1.0</p>
        </div>
      </Container>
    </footer>
  )
}

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

components/ui/alert.tsx

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

/**
 * Alert
 *
 * A block-level message attached to a region of the page. The icon is chosen
 * from the tone, and `role` switches to `alert` for danger so screen readers
 * interrupt — informational tones use the polite `status` role instead.
 */
export type AlertTone = 'info' | 'success' | 'warning' | 'danger' | 'neutral'

export interface AlertProps {
  tone?: AlertTone
  title?: ReactNode
  children?: ReactNode
  /** Rendered under the body — usually one or two buttons. */
  actions?: ReactNode
  /** Slot for a dismiss control supplied by the caller. */
  trailing?: ReactNode
  className?: string
  /** Force the live-region politeness rather than deriving it from tone. */
  live?: 'off' | 'polite' | 'assertive'
}

const tones = {
  info: { icon: Info, surface: 'bg-info-soft border-info-line', accent: 'text-info' },
  success: {
    icon: CheckCircle2,
    surface: 'bg-success-soft border-success-line',
    accent: 'text-success',
  },
  warning: {
    icon: AlertTriangle,
    surface: 'bg-warning-soft border-warning-line',
    accent: 'text-warning',
  },
  danger: {
    icon: OctagonAlert,
    surface: 'bg-danger-soft border-danger-line',
    accent: 'text-danger',
  },
  neutral: { icon: Info, surface: 'bg-surface-sunken border-line', accent: 'text-ink-muted' },
} as const

export function Alert({
  tone = 'info',
  title,
  children,
  actions,
  trailing,
  className,
  live,
}: AlertProps) {
  const entry = tones[tone]
  const Icon = entry.icon
  const politeness = live ?? (tone === 'danger' ? 'assertive' : 'polite')

  return (
    <div
      role={tone === 'danger' ? 'alert' : 'status'}
      aria-live={politeness === 'off' ? undefined : politeness}
      className={cn('flex gap-3 rounded-md border p-3 text-sm', entry.surface, className)}
    >
      <Icon className={cn('mt-0.5 size-4 shrink-0', entry.accent)} aria-hidden="true" />
      <div className="min-w-0 flex-1">
        {title ? <p className="font-semibold text-ink-strong">{title}</p> : null}
        {children ? <div className={cn('text-ink-muted', title && 'mt-1')}>{children}</div> : null}
        {actions ? <div className="mt-3 flex flex-wrap gap-2">{actions}</div> : null}
      </div>
      {trailing ? <div className="shrink-0">{trailing}</div> : null}
    </div>
  )
}

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

Usage

When list growth is a real goal. Validation runs locally and the success message states plainly that nothing was sent — a demo that pretends to have subscribed you is a demo that lies.

  • Validate on submit, not on every keystroke; clearing the error on input is enough feedback.
  • Say what the list is for. "Product updates" converts better than "Subscribe".

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
  • Invalid email
  • Success

Accessibility

Field wiring
Uses the Field primitive, so the hint and error are both in `aria-describedby`.
Success announcement
The confirmation is an Alert with a polite live region.

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.