Newsletter Signup

Email-capture form with idle/sending/sent/error state machine, custom validators, and overridable labels.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/newsletter-signup.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, forwardRef, type ReactNode, type SyntheticEvent, useCallback, useId, useReducer, useRef, } from "react"; import { CheckCircle2, Loader2, XCircle } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; import { Input } from "../input/input"; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** * Localizable strings for {@link NewsletterSignup}. * * @public */ export type NewsletterSignupLabels = { /** Caption when validation rejects the email. Defaults to a generic message. */ emailInvalid?: string; /** Aria-label / fallback for the email input. Defaults to `"Email address"`. */ emailLabel?: string; /** Generic error message when `onSubmit` rejects without a usable reason. Defaults to `"Something went wrong. Try again."`. */ errorFallback?: string; /** Input placeholder. Defaults to `"you@example.com"`. */ placeholder?: string; /** Caption while the submit promise is in flight. Defaults to `"Subscribing…"`. */ sending?: string; /** Submit button label in idle state. Defaults to `"Subscribe"`. */ submit?: string; /** Success caption shown after a successful submission. Defaults to `"You're on the list. Check your inbox to confirm."`. */ success?: string; /** Caption for the retry control after an error. Defaults to `"Try again"`. */ tryAgain?: string; }; /** * Visual variant for {@link NewsletterSignup}. * * - `inline` — input + button on a single row (default). * - `stacked` — input above, full-width button below. * * @public */ export type NewsletterSignupVariant = "inline" | "stacked"; /** * Status reported to {@link NewsletterSignupProps.onStatusChange}. * * @public */ export type NewsletterSignupStatus = "error" | "idle" | "sending" | "sent"; const DEFAULT_LABELS = { emailInvalid: "Enter a valid email address.", emailLabel: "Email address", errorFallback: "Something went wrong. Try again.", placeholder: "you@example.com", sending: "Subscribing…", submit: "Subscribe", success: "You're on the list. Check your inbox to confirm.", tryAgain: "Try again", } as const satisfies Required<NewsletterSignupLabels>; type State = | { kind: "error"; message: string } | { kind: "idle" } | { kind: "sending" } | { kind: "sent" }; type Action = | { kind: "fail"; message: string } | { kind: "reset" } | { kind: "send" } | { kind: "succeed" }; function reducer(state: State, action: Action): State { switch (action.kind) { case "fail": return { kind: "error", message: action.message }; case "reset": return { kind: "idle" }; case "send": return state.kind === "sending" ? state : { kind: "sending" }; case "succeed": return { kind: "sent" }; } } function actionToStatus(action: Action): NewsletterSignupStatus { switch (action.kind) { case "fail": return "error"; case "reset": return "idle"; case "send": return "sending"; case "succeed": return "sent"; } } function defaultValidate(email: string, label: string): string | true { const trimmed = email.trim(); if (!trimmed) return label; if (!EMAIL_PATTERN.test(trimmed)) return label; return true; } function extractErrorMessage(value: unknown, fallback: string): string { if (value instanceof Error && value.message) return value.message; if (typeof value === "string" && value.length > 0) return value; return fallback; } /** * Props for {@link NewsletterSignup}. * * @public */ export type NewsletterSignupProps = { /** Override the input's autocomplete attribute. Defaults to `"email"`. */ autoComplete?: string; /** Localizable strings. */ labels?: NewsletterSignupLabels; /** Fires whenever the internal status transitions. */ onStatusChange?: (status: NewsletterSignupStatus) => void; /** * Submission handler. The component awaits the returned promise. Throw to * surface an error message — `Error` instances use their `message` and * thrown strings render verbatim; everything else falls back to * `labels.errorFallback`. */ onSubmit: (email: string) => Promise<void> | void; /** * Optional custom validator. Return a string to render as the validation * error, or `true` for valid. Defaults to a basic email regex. */ validate?: (email: string) => string | true; /** Visual variant. Defaults to `"inline"`. */ variant?: NewsletterSignupVariant; } & Omit<ComponentPropsWithoutRef<"form">, "onSubmit">; type SubmitButtonProps = { labels: Required<NewsletterSignupLabels>; stacked: boolean; status: NewsletterSignupStatus; }; function SubmitButton({ labels, stacked, status, }: SubmitButtonProps): ReactNode { let content: ReactNode; if (status === "sending") { content = ( <> <Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" /> {labels.sending} </> ); } else if (status === "error") { content = ( <> <XCircle aria-hidden="true" className="mr-2 size-4" /> {labels.tryAgain} </> ); } else { content = labels.submit; } return ( <Button className={stacked ? "w-full" : ""} disabled={status === "sending"} type="submit" > {content} </Button> ); } type SuccessPanelProps = { className?: string; message: string; }; function SuccessPanel({ className, message }: SuccessPanelProps): ReactNode { return ( <div aria-live="polite" className={cn( "flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm text-emerald-900 dark:text-emerald-200", className, )} role="status" > <CheckCircle2 aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> <span>{message}</span> </div> ); } /** * Email-capture compound with a built-in state machine for the universal * "drop your email" pattern. Composes {@link Input} and {@link Button}. * * State machine: `idle → sending → sent | error`. `error → sending` when * the user retries; `error → idle` when they edit the input. Status * changes are also reported via `onStatusChange` so callers can drive * external UI off the same machine. * * @example * ```tsx * <NewsletterSignup * onSubmit={async (email) => subscribe(email)} * labels={{ submit: "Join", success: "Welcome aboard." }} * /> * ``` * * @public */ type SignupController = { errorMessage: null | string; handleChange: () => void; handleSubmit: (event: SyntheticEvent<HTMLFormElement>) => void; inputRef: React.RefObject<HTMLInputElement | null>; status: NewsletterSignupStatus; }; type ControllerOptions = { labels: Required<NewsletterSignupLabels>; onStatusChange?: (status: NewsletterSignupStatus) => void; onSubmit: (email: string) => Promise<void> | void; validate?: (email: string) => string | true; }; function useNewsletterSignupController( options: ControllerOptions, ): SignupController { const { labels, onStatusChange, onSubmit, validate } = options; const inputRef = useRef<HTMLInputElement>(null); const [state, dispatch] = useReducer(reducer, { kind: "idle" }); const status: NewsletterSignupStatus = state.kind; const transition = useCallback( (action: Action) => { dispatch(action); onStatusChange?.(actionToStatus(action)); }, [onStatusChange], ); const handleChange = useCallback(() => { if (state.kind === "error") transition({ kind: "reset" }); }, [state.kind, transition]); const performSubmit = useCallback( async (value: string) => { const validator = validate ?? ((email: string) => defaultValidate(email, labels.emailInvalid)); const validation = validator(value); if (validation !== true) { transition({ kind: "fail", message: validation }); inputRef.current?.focus(); return; } transition({ kind: "send" }); try { await onSubmit(value); transition({ kind: "succeed" }); } catch (error) { transition({ kind: "fail", message: extractErrorMessage(error, labels.errorFallback), }); inputRef.current?.focus(); } }, [labels.emailInvalid, labels.errorFallback, onSubmit, transition, validate], ); const handleSubmit = useCallback( (event: SyntheticEvent<HTMLFormElement>) => { event.preventDefault(); if (state.kind === "sending") return; const value = inputRef.current?.value.trim() ?? ""; void performSubmit(value); }, [performSubmit, state.kind], ); return { errorMessage: state.kind === "error" ? state.message : null, handleChange, handleSubmit, inputRef, status, }; } export const NewsletterSignup = forwardRef< HTMLFormElement, NewsletterSignupProps >((props, ref) => { const { autoComplete = "email", className, labels, onStatusChange, onSubmit, validate, variant = "inline", ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const inputId = useId(); const errorId = useId(); const controller = useNewsletterSignupController({ labels: resolvedLabels, onStatusChange, onSubmit, validate, }); if (controller.status === "sent") { return ( <SuccessPanel className={className} message={resolvedLabels.success} /> ); } return ( <FormBody autoComplete={autoComplete} className={className} errorId={errorId} formRef={ref} inputId={inputId} inputRef={controller.inputRef} labels={resolvedLabels} message={controller.errorMessage} onChange={controller.handleChange} onSubmit={controller.handleSubmit} rest={rest} stacked={variant === "stacked"} status={controller.status} /> ); }); NewsletterSignup.displayName = "NewsletterSignup"; type FormBodyProps = { autoComplete: string; className?: string; errorId: string; formRef: React.ForwardedRef<HTMLFormElement>; inputId: string; inputRef: React.RefObject<HTMLInputElement | null>; labels: Required<NewsletterSignupLabels>; message: null | string; onChange: () => void; onSubmit: (event: SyntheticEvent<HTMLFormElement>) => void; rest: Omit<ComponentPropsWithoutRef<"form">, "onSubmit">; stacked: boolean; status: NewsletterSignupStatus; }; function FormBody({ autoComplete, className, errorId, formRef, inputId, inputRef, labels, message, onChange, onSubmit, rest, stacked, status, }: FormBodyProps): ReactNode { return ( <form aria-busy={status === "sending"} className={cn( "flex w-full", stacked ? "flex-col gap-2" : "flex-col gap-2 sm:flex-row sm:items-start", className, )} noValidate onSubmit={onSubmit} ref={formRef} {...rest} > <div className={cn("flex flex-col gap-1", stacked ? "" : "sm:flex-1")}> <label className="sr-only" htmlFor={inputId}> {labels.emailLabel} </label> <Input aria-describedby={message ? errorId : undefined} aria-invalid={message !== null} autoComplete={autoComplete} disabled={status === "sending"} id={inputId} name="email" onChange={onChange} placeholder={labels.placeholder} ref={inputRef} type="email" /> <p aria-live="polite" className={cn("text-xs", message ? "text-destructive" : "sr-only")} id={errorId} role={message ? "alert" : undefined} > {message ?? ""} </p> </div> <SubmitButton labels={labels} stacked={stacked} status={status} /> </form> ); } export { reducer as newsletterSignupReducer };

Dependencies

  • @vllnt/ui@^0.2.1
  • lucide-react