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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/newsletter-signup.jsonPrompt
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
statusinstead 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, notdisabled— 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-busyannounce 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 leta@bthrough), and each transition is spoken exactly once: pending by a permanently mounted status region, confirmation by the panel itself, failure by an alert.