Blocks

Newsletter Signup

An email capture band built around its submit state machine — submitting, confirmed, already-subscribed, and a failure that keeps the address you typed.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, LoaderCircle, Mail, MailCheck } from "lucide-react"
import { z } from "zod"

import { cn } from "@/lib/utils"

/**
 * The single validation rule, exported so the host can run the identical check
 * server-side. zod owns validation on purpose: `type="email"` is kept only for
 * the mobile keyboard, and the browser's own rule accepts `a@b` (no TLD) —
 * which every mail provider bounces. `noValidate` on the form keeps the two
 * from fighting over the same field.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/newsletter-signup.json

Prompt

Build a React + TypeScript + Tailwind "NewsletterSignup" block (zod for
validation, lucide-react for icons).

Contract
- onSubmit: (email: string) => Promise<{ status: "subscribed" |
  "already-subscribed"; message?: string } | void> is the only required prop.
  The host owns the network call; the block never fetches. Resolve to confirm,
  resolve with "already-subscribed" for the idempotent notice, reject to show a
  retryable error.
- Everything visible is an optional copy prop with a default: heading,
  description, defaultEmail, emailLabel, placeholder, submitLabel,
  pendingLabel, retryLabel, emptyMessage, invalidMessage, failureMessage,
  successTitle/successDescription, alreadyTitle/alreadyDescription, resetLabel,
  footnote (ReactNode), className. Rest props and the forwarded ref land on the
  root section; `children` is omitted from the props type because the block
  owns its own content.
- Export the zod rule (z.email()) so the host can run the identical check on
  the server instead of writing a second, subtly different regex.

Behavior
- One state machine: idle -> submitting -> success | already | error, plus a
  field-level invalid flash that never leaves idle.
- Submission is a real form element with an onSubmit handler and noValidate.
  A real form is what makes Enter in the field submit; noValidate is what stops
  the browser's own validation bubble from racing zod. type="email" stays, but
  only for the mobile keyboard: the browser's rule accepts "a@b" (no TLD),
  z.email() rejects it, and only one of the two may be the source of truth.
- Blank input and malformed input get their own messages. Both render in a
  role="alert" line that the input points at with aria-describedby, both set
  aria-invalid on the field, both move focus back into it, and neither calls
  onSubmit. The field-level error clears on the next keystroke; a submit
  failure does not, so the retry keeps its explanation.
- Duplicate-submit guard is a ref flipped synchronously at the top of the
  handler, before any await — not the status state. Several presses inside one
  tick would all read the same stale state and fire several requests. The
  button carries aria-disabled + aria-busy instead of the native disabled
  attribute, because the browser blurs a control the instant it is disabled:
  a keyboard user who just pressed Enter would be dropped onto the document
  body with the pending state announced from nowhere.
- A rejection keeps everything the visitor typed. The message is Error.message
  when there is one, otherwise failureMessage; the button relabels to
  retryLabel; nothing auto-dismisses it; pressing again resubmits the same
  address with no retyping.
- Resolving replaces the form with a role="status" confirmation panel that
  repeats the exact address that was accepted — a confirmation that cannot say
  which inbox to check is not a confirmation. Resolving with
  { status: "already-subscribed" } renders the same terminal panel with the
  idempotent copy, so signing up twice never reads as a failure. A result
  message, when present, replaces the built-in line verbatim.
- The panel offers "Use a different email": back to idle, field cleared, focus
  moved into the input (via a ref flag read in an effect, so it survives the
  form being mounted again).
- Async hygiene: onSubmit is held in a latest-ref so an inline arrow function
  never invalidates an effect; a mounted ref gates every setState after the
  await; the pending ref is released on both the resolve and the reject path.

Rendering & styling
- Semantic tokens only: bg-card panel with a border, bg-background input with
  border-input, bg-primary/text-primary-foreground on the submit button and the
  confirmation medallion, text-muted-foreground for supporting copy,
  text-destructive on bg-destructive/10 for the alert line, bg-muted/40 for the
  confirmation panel. cn() merges className.
- Field and button stack on mobile and become a row from sm:, both h-11 so they
  align; a mail icon sits inside the field with an explicit size (an svg with
  no explicit height falls back to its 300x150 intrinsic box) and
  pointer-events-none so it never eats a click. Logical properties (ps/pe,
  start) so RTL mirrors correctly.
- Accessibility: sr-only label bound with htmlFor (a placeholder is not a
  label), focus-visible ring on every control, section labelled by its heading.
  Live regions: one always-mounted sr-only role="status" carrying the pending
  label, the confirmation panel is itself role="status", failures are
  role="alert" — one announcement per transition, nothing announced twice.
- Motion: the spinner is animate-spin motion-reduce:animate-none and colour
  transitions are motion-reduce:transition-none. With motion off, every state
  is still readable from the button label and the live regions.

Customization levers
- Chrome and alignment: the root is a bordered bg-card panel wrapping a
  centered max-w-xl column. Drop border/bg for a bare band inside an existing
  section, or swap text-center for text-start and remove items-center for a
  footer strip where the form sits under the copy.
- Layout: remove sm:flex-row for a permanently stacked field + full-width
  button; widen max-w-xl for a hero-width band.
- Copy: every string is a prop, and the server can override the confirmation
  line at runtime by resolving with { message }.
- Extra fields (first name, a consent checkbox): add siblings inside the same
  form element, extend the zod check, widen the onSubmit argument to an object.
  The state machine is untouched — it only cares about resolve/reject.
- Double opt-in: keep the default success copy pointing at the confirmation
  email, or resolve with a server message describing exactly what was sent.
- Auto-dismiss: the confirmation is terminal on purpose. If you want it to
  return to the form by itself, call the same reset the button calls from a
  timer, and clear that timer on unmount.

Concepts

  • Guard before the await — the duplicate-submit lock is a ref flipped synchronously at the top of the handler. A guard that reads status instead loses the race against a burst of presses inside one tick, because every handler in that tick sees the same pre-update value.
  • aria-disabled, not disabled — the native attribute blurs the control the moment it is applied, so the keyboard user who just submitted loses their place on the page. aria-disabled + aria-busy announce the same unavailability while the ref guard does the actual blocking.
  • Failure keeps the input — the most common and most infuriating bug in signup forms is a rejection that also wipes the field. Here the error branch touches nothing but the status and the message, so "Try again" really is one press.
  • Idempotent success — "you are already on this list" is the visitor's goal, already met. It resolves through the success path with its own copy instead of being dressed up as an error they are expected to fix.
  • One validator, one announcement — zod is the only rule (the form is noValidate; type="email" is there for the phone keyboard, and its looser native rule would otherwise let a@b through), and each transition is spoken exactly once: pending by a permanently mounted status region, confirmation by the panel itself, failure by an alert.

On This Page