Inline newsletter
One row: heading, field and button, with the privacy line in the pattern.
Live preview
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.
Related
All sectionsNewsletter footer
A validated subscribe form as the footer’s primary content, with an honest success message.
intermediate3 variantsNewsletter with topics
Topic selection, because choice reduces unsubscribes more than frequency does.
intermediate3 variantsInverted newsletter band
A full-bleed dark band where the input keeps its own light surface.
starter3 variants