Skip to content

Inverted newsletter band

A full-bleed dark band where the input keeps its own light surface.

Marketingstarternewsletterinvertedbandcontrast

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

/**
 * Inverted newsletter band
 *
 * A full-bleed dark band used as page punctuation. Because the surrounding
 * surface is inverted, the input keeps its own light surface rather than
 * inheriting — a transparent field on a dark band is where contrast failures
 * usually appear.
 */
export default function InvertedBandNewsletter() {
  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="bg-surface-inverse py-section text-ink-inverse">
      <Container size="narrow" className="text-center">
        <h2 className="display-type text-2xl leading-tight font-semibold sm:text-3xl">
          Release notes, roughly monthly.
        </h2>
        <p className="mx-auto mt-3 max-w-md text-md opacity-80">
          Written by the people who made the change, not by marketing.
        </p>

        {done ? (
          <p
            role="status"
            className="mx-auto mt-8 max-w-md rounded-lg border border-white/20 bg-white/10 px-4 py-3 text-sm"
          >
            Subscribed. This is a template demo — no submission was sent.
          </p>
        ) : (
          <form
            onSubmit={submit}
            noValidate
            className="mx-auto mt-8 flex max-w-md flex-col gap-3 sm:flex-row sm:items-start"
          >
            <Field
              name="newsletter-inverted"
              label="Email address"
              hideLabel
              error={error}
              className="flex-1 text-left [&_p]:text-danger"
            >
              {(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" variant="secondary" className="shrink-0">
              Subscribe
            </Button>
          </form>
        )}

        <p className="mt-4 text-xs opacity-60">One click to unsubscribe. No tracking pixels.</p>
      </Container>
    </section>
  )
}

components/blocks/sections/newsletter/inverted-band.tsx

'use client'

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

/**
 * Inverted newsletter band
 *
 * A full-bleed dark band used as page punctuation. Because the surrounding
 * surface is inverted, the input keeps its own light surface rather than
 * inheriting — a transparent field on a dark band is where contrast failures
 * usually appear.
 */
export default function InvertedBandNewsletter() {
  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="bg-surface-inverse py-section text-ink-inverse">
      <Container size="narrow" className="text-center">
        <h2 className="display-type text-2xl leading-tight font-semibold sm:text-3xl">
          Release notes, roughly monthly.
        </h2>
        <p className="mx-auto mt-3 max-w-md text-md opacity-80">
          Written by the people who made the change, not by marketing.
        </p>

        {done ? (
          <p
            role="status"
            className="mx-auto mt-8 max-w-md rounded-lg border border-white/20 bg-white/10 px-4 py-3 text-sm"
          >
            Subscribed. This is a template demo — no submission was sent.
          </p>
        ) : (
          <form
            onSubmit={submit}
            noValidate
            className="mx-auto mt-8 flex max-w-md flex-col gap-3 sm:flex-row sm:items-start"
          >
            <Field
              name="newsletter-inverted"
              label="Email address"
              hideLabel
              error={error}
              className="flex-1 text-left [&_p]:text-danger"
            >
              {(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" variant="secondary" className="shrink-0">
              Subscribe
            </Button>
          </form>
        )}

        <p className="mt-4 text-xs opacity-60">One click to unsubscribe. No tracking pixels.</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

A transparent field on a dark band is where contrast failures usually appear, so the input keeps the standard control surface rather than inheriting.

  • Never make the field transparent on an inverted band.
  • Keep the supporting copy at 80% opacity; lower loses legibility.

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.

  • Inverted surface
  • Light input surface
  • Centred layout

Accessibility

Field contrast
The input retains its own surface and border tokens.
Status role
The subscribed message 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.