Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
blocksection

Newsletter

An email capture band that reports its own outcome through a live region that is present from first render.

Preview

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/newsletter.json

Pulls in button, container, input, section, section-header, types, utils. The CLI installs them for you.

Usage

The exact source of the preview above.

demos/newsletter.tsx
"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.

components/sections/newsletter.tsx
"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.

Props for NewsletterProps
PropTypeDefault
title*Requiredstring
descriptionstring
eyebrowstring
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
labelstring"Email address"
hideLabelbooleantrue
placeholderstring"you@example.com"
submitLabelstring"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"