Skip to content
goodtabs UI
BrowseBrowse components

Email signup

One line email field with a submit button, validation, pending and success states.

npx shadcn@latest add https://ui.goodtabs.co/r/email-signup.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/email-signup

Field and button in one row. An invalid address shows an inline error.

Centered with copy

New components every week. One email, no noise.

align center under a line of copy, as in a footer or a hero.

Pending state

onSubmit returns a promise, so the button spins until it settles.

Usage

import { EmailSignup } from "@/components/ui/email-signup"

<EmailSignup onSubmit={async (email) => { await subscribe(email) }} />

Props

PropTypeDefaultDescription
onSubmit(email: string) => void | Promise<void>Called with the trimmed address. A promise shows a pending state until it settles.
placeholderstring"you@example.com"Placeholder in the field.
buttonLabelstring"Subscribe"Label of the submit button.
successTextstring"Check your inbox"Shown with a check mark once the submit settles.
align"start" | "center""start"Left aligned, or centered with the error text centered too.

Source

email-signup/email-signup.tsx
"use client";

import * as React from "react";
import { CheckIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

type EmailSignupProps = Omit<React.ComponentProps<"form">, "onSubmit"> & {
  /** Called with the address. Return a promise to show a pending state until it settles. */
  onSubmit?: (email: string) => void | Promise<void>;
  /** Placeholder in the field. */
  placeholder?: string;
  /** Label of the submit button. */
  buttonLabel?: string;
  /** Shown with a check mark once the submit settles. */
  successText?: string;
  /** Left aligned by default; center for a hero or a footer column. */
  align?: "start" | "center";
};

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

/**
 * One line: an email field with the submit button on the right.
 * Validates the address, shows a pending state and swaps the row for a success line.
 */
export function EmailSignup({
  onSubmit,
  placeholder = "you@example.com",
  buttonLabel = "Subscribe",
  successText = "Check your inbox",
  align = "start",
  className,
  ...props
}: EmailSignupProps) {
  const id = React.useId();
  const [email, setEmail] = React.useState("");
  const [status, setStatus] = React.useState<"idle" | "pending" | "done">("idle");
  const [error, setError] = React.useState<string | null>(null);

  const submit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const value = email.trim();
    if (!EMAIL.test(value)) {
      setError("Enter a valid email address");
      return;
    }
    setError(null);
    try {
      setStatus("pending");
      await onSubmit?.(value);
      setStatus("done");
    } catch {
      setStatus("idle");
      setError("Something went wrong, try again");
    }
  };

  const pending = status === "pending";
  const centered = align === "center";

  return (
    <form
      noValidate
      onSubmit={submit}
      data-slot="email-signup"
      data-status={status}
      className={cn("flex w-full max-w-sm flex-col gap-1.5", centered && "mx-auto items-center text-center", className)}
      {...props}
    >
      {status === "done" ? (
        <div role="status" className="flex h-8 items-center gap-2 text-sm font-medium text-foreground">
          <CheckIcon className="size-4" aria-hidden="true" />
          <span>{successText}</span>
        </div>
      ) : (
        <>
          <div className="flex w-full items-center gap-2">
            <label htmlFor={id} className="sr-only">
              Email address
            </label>
            <Input
              id={id}
              type="email"
              name="email"
              autoComplete="email"
              inputMode="email"
              placeholder={placeholder}
              value={email}
              onChange={(e) => {
                setEmail(e.target.value);
                if (error) setError(null);
              }}
              disabled={pending}
              aria-invalid={error ? true : undefined}
              aria-describedby={error ? `${id}-error` : undefined}
              className="flex-1"
            />
            <Button type="submit" disabled={pending} aria-busy={pending || undefined}>
              {pending && (
                <Loader2Icon
                  data-icon="inline-start"
                  className="animate-spin motion-reduce:animate-none"
                  aria-hidden="true"
                />
              )}
              {buttonLabel}
            </Button>
          </div>
          <p id={`${id}-error`} aria-live="polite" className={cn("m-0 text-xs text-destructive", !error && "sr-only")}>
            {error ?? ""}
          </p>
        </>
      )}
    </form>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/email-signup/prompt.md, or the Copy AI prompt button above.

When to use

  • Newsletter, waitlist and changelog signups on a landing page or footer
  • Anywhere one email address is the whole form
  • When the result should stay inline, with no toast or page change

When not to use

  • Forms that need a name, a password or consent boxes: build a full form
  • Signups that must confirm by redirect, where the success line would be wrong

Agent notes

  • onSubmit is a function, so the component that renders EmailSignup must be a client component ("use client")
  • Validation is a simple shape check on the client; validate again on the server
  • If onSubmit throws, the row stays and a generic error shows; log the error where you call it
  • It is a form element with noValidate, so do not nest it inside another form; the row is max-w-sm by default