blocksection
Newsletter
An email capture band that reports its own outcome through a live region that is present from first render.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/newsletter.jsonPulls in button, container, input, section, section-header, types, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
"use client"
import { useState } from "react"
import { Newsletter, type NewsletterStatus } from "@registry/sections/newsletter"
export function Demo() {
const [status, setStatus] = useState<NewsletterStatus>("idle")
// Stands in for a server action or a fetch. The section owns no network call.
const submit = (email: string) => {
if (!email.includes("@")) {
setStatus("error")
return
}
setStatus("submitting")
window.setTimeout(() => setStatus("success"), 900)
}
return (
<Newsletter
eyebrow="Changelog"
title="One email when something ships"
description="No drip campaign, no webinar invitations."
onSubmit={submit}
status={status}
errorMessage="Enter a valid email address."
note="Unsubscribe in one click. We never share your address."
/>
)
}Source
What the CLI copies into your project, byte for byte.
"use client"
/**
* Newsletter — an email capture band.
*
* Presentation and wiring only: it owns no network call. Pass `onSubmit` and
* drive `status` from whatever your form action or mutation returns, so the
* section works the same behind a server action, a fetch, or a third-party
* embed.
*
* a11y: a form that reports its own outcome, which is the part most newsletter
* sections skip.
*
* - The result is announced through a polite live region that is present from
* the first render. A live region inserted *at the same time* as its message
* is frequently missed by screen readers, so the container is always there
* and only its content changes.
* - While submitting, the button is `aria-busy` and keeps its label — the
* accessible name never changes under the user.
* - An error is passed to the field as well as the live region, so it is both
* announced and programmatically tied to the input that caused it.
* - The label is real. `hideLabel` moves it out of the layout, not out of the
* accessibility tree, which is what a placeholder-only field does.
*
* Dependencies: react, @/lib/types, @/lib/utils,
* @/components/ui/button, @/components/ui/container, @/components/ui/input,
* @/components/ui/section, @/components/ui/section-header.
*/
import type { FormEvent, ReactNode } from "react"
import type { SectionBaseProps } from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Container } from "@/components/ui/container"
import { Input } from "@/components/ui/input"
import { Section } from "@/components/ui/section"
import { SectionHeader } from "@/components/ui/section-header"
export type NewsletterStatus = "idle" | "submitting" | "success" | "error"
export interface NewsletterProps extends SectionBaseProps {
title: string
description?: string
eyebrow?: string
/** Called with the submitted address. Keep the network call outside. */
onSubmit?: (email: string) => void
/** Drives the button, the live region, and the field's error state. */
status?: NewsletterStatus
/** Announced on success. */
successMessage?: ReactNode
/** Announced on failure and attached to the field. */
errorMessage?: string
label?: string
hideLabel?: boolean
placeholder?: string
submitLabel?: string
/** Small print under the form — consent, frequency, unsubscribe. */
note?: ReactNode
/** `inline` puts the button inside the field; `stacked` puts it below. */
layout?: "inline" | "stacked"
variant?: "plain" | "muted" | "card"
align?: "start" | "center"
}
const surfaces = {
plain: "",
muted: "bg-muted rounded-2xl px-6 py-12 sm:px-12",
card: "bg-card text-card-foreground rounded-2xl border px-6 py-12 sm:px-12",
} as const
export function Newsletter({
title,
description,
eyebrow,
onSubmit,
status = "idle",
successMessage = "Thanks — check your inbox to confirm.",
errorMessage,
label = "Email address",
hideLabel = true,
placeholder = "you@example.com",
submitLabel = "Subscribe",
note,
layout = "inline",
variant = "muted",
align = "center",
headingLevel = 2,
as = "section",
spacing = "md",
className,
id,
}: NewsletterProps) {
const titleId = id ? `${id}-title` : slugId(title)
const submitting = status === "submitting"
const centered = align === "center"
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!onSubmit) return
const data = new FormData(event.currentTarget)
onSubmit(String(data.get("email") ?? ""))
}
return (
<Section as={as} spacing={spacing} labelledBy={titleId} id={id} className={className}>
<Container size="md">
<div
className={cn(
"flex flex-col gap-8",
surfaces[variant],
centered && "items-center",
)}
>
<SectionHeader
align={align}
eyebrow={eyebrow}
title={title}
description={description}
headingLevel={headingLevel}
titleId={titleId}
/>
<form
onSubmit={handleSubmit}
noValidate
className={cn(
"flex w-full max-w-md flex-col gap-3",
centered && "items-center",
)}
>
<div
className={cn(
"flex w-full gap-3",
layout === "stacked" && "flex-col",
layout === "inline" && "flex-col sm:flex-row",
)}
>
<Input
name="email"
type="email"
autoComplete="email"
required
label={label}
hideLabel={hideLabel}
placeholder={placeholder}
error={status === "error" ? errorMessage : undefined}
disabled={submitting}
/>
<Button
type="submit"
loading={submitting}
loadingLabel="Subscribing"
className={cn(
"h-11 shrink-0",
layout === "stacked" ? "w-full" : "w-full sm:w-auto",
)}
>
{submitLabel}
</Button>
</div>
{note ? (
<p
className={cn(
"text-muted-foreground text-pretty text-sm",
centered && "text-center",
)}
>
{note}
</p>
) : null}
{/*
Always rendered, never conditionally mounted: a live region that
appears at the same moment as its message is routinely missed.
*/}
<p
aria-live="polite"
className={cn(
"text-pretty text-sm",
status === "error" ? "text-destructive" : "text-muted-foreground",
centered && "text-center",
)}
>
{status === "success" ? successMessage : null}
{status === "error" ? errorMessage : null}
</p>
</form>
</div>
</Container>
</Section>
)
}Props
NewsletterProps
Extends SectionBaseProps.
| Prop | Type | Default |
|---|---|---|
title*Required | string | — |
description | string | — |
eyebrow | string | — |
onSubmitCalled with the submitted address. Keep the network call outside. | (email: string) => void | — |
statusDrives the button, the live region, and the field's error state. | NewsletterStatus | "idle" |
successMessageAnnounced on success. | ReactNode | "Thanks — check your inbox to confirm." |
errorMessageAnnounced on failure and attached to the field. | string | — |
label | string | "Email address" |
hideLabel | boolean | true |
placeholder | string | "you@example.com" |
submitLabel | string | "Subscribe" |
noteSmall print under the form — consent, frequency, unsubscribe. | ReactNode | — |
layout`inline` puts the button inside the field; `stacked` puts it below. | "inline" | "stacked" | "inline" |
variant | "plain" | "muted" | "card" | "muted" |
align | "start" | "center" | "center" |