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
| Prop | Type | Default | Description |
|---|---|---|---|
| onSubmit | (email: string) => void | Promise<void> | Called with the trimmed address. A promise shows a pending state until it settles. | |
| placeholder | string | "you@example.com" | Placeholder in the field. |
| buttonLabel | string | "Subscribe" | Label of the submit button. |
| successText | string | "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