Newsletter footer
A validated subscribe form as the footer’s primary content, with an honest success message.
Live preview
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.
Related
All navigationInline newsletter
One row: heading, field and button, with the privacy line in the pattern.
starter3 variantsField
The accessibility contract shared by every form control — label, help text, error, success and the ARIA wiring that connects them.
intermediateFeatured7 variantsSimple contact
Three fields with a live character budget and both minimum and maximum length rules.
starterFeatured4 variants