Inverted newsletter band
A full-bleed dark band where the input keeps its own light surface.
Live preview
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.
Related
All sectionsInline newsletter
One row: heading, field and button, with the privacy line in the pattern.
starter3 variantsInverted CTA panel
A dark rounded panel inside a light page.
starter3 variantsInverted statistics panel
A dark band of four derived figures, used as punctuation between light sections.
starter3 variants