# Newsletter Signup

> Newsletter form with validation, loading and success states.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/newsletter-signup
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/newsletter-signup.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/newsletter-signup-demo.tsx"
"use client"

import { NewsletterSignup } from "@/components/newsletter-signup"

const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))

export default function NewsletterSignupDemo() {
  return (
    <div className="flex w-full max-w-md flex-col items-center gap-2 text-center">
      <h2 className="text-2xl font-semibold tracking-tight">Stay in the loop</h2>
      <p className="mb-4 text-sm text-muted-foreground">New components and design notes, twice a month.</p>
      <NewsletterSignup onSubscribe={() => wait(1200)} hint="No spam. Unsubscribe at any time." />
    </div>
  )
}
```

## Source

```tsx title="components/newsletter-signup.tsx"
"use client"

import * as React from "react"
import { ArrowRight, CircleCheck, LoaderCircle, Mail } from "lucide-react"

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

type Status = "idle" | "loading" | "success" | "error"

export interface NewsletterSignupProps extends Omit<React.ComponentProps<"form">, "onSubmit"> {
  /** Resolve to subscribe, throw (or reject) with a message to show an error */
  onSubscribe?: (email: string) => Promise<void> | void
  placeholder?: string
  buttonLabel?: string
  successMessage?: string
  /** "inline" puts the button inside the field, "stacked" below it */
  layout?: "inline" | "stacked"
  hint?: React.ReactNode
}

const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

export function NewsletterSignup({
  onSubscribe,
  placeholder = "you@company.com",
  buttonLabel = "Subscribe",
  successMessage = "You're on the list. Check your inbox to confirm.",
  layout = "inline",
  hint,
  className,
  ...props
}: NewsletterSignupProps) {
  const [email, setEmail] = React.useState("")
  const [status, setStatus] = React.useState<Status>("idle")
  const [error, setError] = React.useState("")
  const messageId = React.useId()

  const submit = async (event: React.FormEvent) => {
    event.preventDefault()
    if (!emailPattern.test(email.trim())) {
      setStatus("error")
      setError("Please enter a valid email address.")
      return
    }
    setStatus("loading")
    try {
      await onSubscribe?.(email.trim())
      setStatus("success")
    } catch (reason) {
      setStatus("error")
      setError(reason instanceof Error ? reason.message : "Something went wrong. Please try again.")
    }
  }

  if (status === "success") {
    return (
      <div
        role="status"
        className={cn(
          "flex w-full max-w-md items-center gap-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700 animate-in fade-in zoom-in-95 dark:text-emerald-300",
          className
        )}
      >
        <CircleCheck className="size-5 shrink-0" />
        {successMessage}
      </div>
    )
  }

  const loading = status === "loading"
  const button = (
    <button
      type="submit"
      disabled={loading}
      className={cn(
        "inline-flex shrink-0 items-center justify-center gap-1.5 bg-primary text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:opacity-70",
        layout === "inline" ? "h-9 rounded-lg px-4" : "h-11 w-full rounded-xl"
      )}
    >
      {loading ? <LoaderCircle className="size-4 animate-spin" /> : buttonLabel}
      {!loading && layout === "stacked" && <ArrowRight className="size-4" />}
    </button>
  )

  return (
    <form noValidate onSubmit={submit} className={cn("flex w-full max-w-md flex-col gap-2", className)} {...props}>
      <div
        className={cn(
          "flex items-center gap-2 rounded-xl border bg-background shadow-xs transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20",
          layout === "inline" ? "py-1 pr-1 pl-3.5" : "h-11 px-3.5",
          status === "error" && "border-destructive focus-within:border-destructive focus-within:ring-destructive/20"
        )}
      >
        <Mail className="size-4 shrink-0 text-muted-foreground" />
        <input
          type="email"
          value={email}
          onChange={(event) => {
            setEmail(event.target.value)
            if (status === "error") setStatus("idle")
          }}
          placeholder={placeholder}
          aria-label="Email address"
          aria-invalid={status === "error"}
          aria-describedby={messageId}
          autoComplete="email"
          className="h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
        />
        {layout === "inline" && button}
      </div>
      {layout === "stacked" && button}
      <p id={messageId} className={cn("px-1 text-xs", status === "error" ? "text-destructive" : "text-muted-foreground")}>
        {status === "error" ? error : hint}
      </p>
    </form>
  )
}
```
