Skip to content

Inline newsletter

One row: heading, field and button, with the privacy line in the pattern.

Marketingstarternewsletterinlineemailsubscribe

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 { CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'

/**
 * Inline newsletter
 *
 * One row: heading, field, button. The privacy line is part of the pattern,
 * not an afterthought — telling people what happens to their address before
 * they type it measurably improves completion.
 */
export default function InlineNewsletter() {
  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) return setError('Enter an email address.')
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
      return setError('Enter a valid email address, for example name@company.com.')
    }
    setError(null)
    setDone(true)
  }

  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow">
        <div className="grid items-start gap-6 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
          <div>
            <h2 className="display-type text-xl font-semibold text-ink-strong sm:text-2xl">
              One email when something meaningful ships.
            </h2>
            <p className="mt-2 text-sm text-ink-muted">
              New components, breaking changes, and the occasional note on why a decision went the
              way it did.
            </p>
          </div>

          {done ? (
            <div
              role="status"
              className="flex items-center gap-2.5 rounded-lg border border-success-line bg-success-soft px-4 py-3 text-sm"
            >
              <CheckCircle2 className="size-4 shrink-0 text-success" aria-hidden="true" />
              <span className="text-ink">
                Subscribed. This is a template demo — no submission was sent.
              </span>
            </div>
          ) : (
            <form
              onSubmit={submit}
              noValidate
              className="flex flex-col gap-3 sm:flex-row sm:items-start"
            >
              <Field
                name="newsletter-inline"
                label="Email address"
                hideLabel
                error={error}
                className="flex-1"
              >
                {(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="shrink-0">
                Subscribe
              </Button>
            </form>
          )}
        </div>

        <p className="mt-4 text-xs text-ink-subtle">
          No tracking, no sharing, and one click to unsubscribe. In this demo nothing leaves your
          browser.
        </p>
      </Container>
    </section>
  )
}

components/blocks/sections/newsletter/inline.tsx

'use client'

import { useState, type FormEvent } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'

/**
 * Inline newsletter
 *
 * One row: heading, field, button. The privacy line is part of the pattern,
 * not an afterthought — telling people what happens to their address before
 * they type it measurably improves completion.
 */
export default function InlineNewsletter() {
  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) return setError('Enter an email address.')
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
      return setError('Enter a valid email address, for example name@company.com.')
    }
    setError(null)
    setDone(true)
  }

  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow">
        <div className="grid items-start gap-6 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
          <div>
            <h2 className="display-type text-xl font-semibold text-ink-strong sm:text-2xl">
              One email when something meaningful ships.
            </h2>
            <p className="mt-2 text-sm text-ink-muted">
              New components, breaking changes, and the occasional note on why a decision went the
              way it did.
            </p>
          </div>

          {done ? (
            <div
              role="status"
              className="flex items-center gap-2.5 rounded-lg border border-success-line bg-success-soft px-4 py-3 text-sm"
            >
              <CheckCircle2 className="size-4 shrink-0 text-success" aria-hidden="true" />
              <span className="text-ink">
                Subscribed. This is a template demo — no submission was sent.
              </span>
            </div>
          ) : (
            <form
              onSubmit={submit}
              noValidate
              className="flex flex-col gap-3 sm:flex-row sm:items-start"
            >
              <Field
                name="newsletter-inline"
                label="Email address"
                hideLabel
                error={error}
                className="flex-1"
              >
                {(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="shrink-0">
                Subscribe
              </Button>
            </form>
          )}
        </div>

        <p className="mt-4 text-xs text-ink-subtle">
          No tracking, no sharing, and one click to unsubscribe. In this demo nothing leaves your
          browser.
        </p>
      </Container>
    </section>
  )
}

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

Telling people what happens to their address before they type it measurably improves completion, so the privacy line is part of the pattern rather than an afterthought.

  • Validate on submit and clear the error on input.
  • State the cadence — 'one email when something ships' 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.

  • Two-column at lg
  • Validation
  • Subscribed state

Accessibility

Hidden label
The field has a real label, visually hidden.
Status role
The subscribed confirmation is announced politely.

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.