Subscribe Input
E-mail field in a pill whose round button carries sending, success and failure.
From the Findit template — see it in contextTry any address, or one ending in .test to see the failure.
"use client";
import { useState } from "react";
import { SubscribeInput, type SubscribeStatus } from "@/components/ui/subscribe-input";
/* The demo answers after a second: an address ending in .test fails, anything else succeeds. */
function fakeSignUp(email: string) {
return new Promise<void>((resolve, reject) => {
setTimeout(() => (email.endsWith(".test") ? reject(new Error("rejected")) : resolve()), 1000);
});
}
export default function SubscribeInputDemo() {
const [status, setStatus] = useState<SubscribeStatus>("idle");
return (
<div className="flex w-full max-w-md flex-col items-center gap-3">
<SubscribeInput onSubmit={fakeSignUp} onStatusChange={setStatus} placeholder="you@example.com" />
<p className="text-center text-xs text-muted-foreground">
{status === "success"
? "Subscribed — the field clears itself."
: status === "error"
? "Something went wrong; what you typed stays."
: "Try any address, or one ending in .test to see the failure."}
</p>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/subscribe-input.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/subscribe-input.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Subscribe Input — an e-mail field in a pill whose round button carries the result (from the Findit newsletter form): * a spinner while sending, a check on success (the field clears), the arrow again on failure (the field keeps what * was typed). Framer hid those states in an invisible button, so the visitor saw nothing happen; Findit moved them * into the button and this keeps that. * * `onSubmit` gets the address and does the work — a server action, fetch, anything. Throwing means failure. * Every state is announced to screen readers through a polite live region. */ export type SubscribeStatus = "idle" | "pending" | "success" | "error"; export type SubscribeInputProps = { onSubmit: (email: string) => void | Promise<void>; placeholder?: string; /** Accessible name of the field; defaults to the placeholder. */ label?: string; submitLabel?: string; successMessage?: string; errorMessage?: string; onStatusChange?: (status: SubscribeStatus) => void; className?: string; }; export function SubscribeInput({ onSubmit, placeholder = "Your email", label, submitLabel = "Subscribe", successMessage = "Thanks — please check your inbox.", errorMessage = "That didn't work. Please try again.", onStatusChange, className, }: SubscribeInputProps) { const [status, setStatus] = React.useState<SubscribeStatus>("idle"); const form = React.useRef<HTMLFormElement>(null); function move(next: SubscribeStatus) { setStatus(next); onStatusChange?.(next); } async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); const data = new FormData(event.currentTarget); /* A bot that fills every field it finds is dropped without a word. */ if (data.get("company")) return; move("pending"); try { await onSubmit(String(data.get("email") ?? "")); move("success"); form.current?.reset(); } catch { move("error"); } } return ( <form ref={form} onSubmit={handleSubmit} className={cn( "flex h-12 w-full max-w-md items-center rounded-full border bg-background pr-1.5 pl-1 transition-[color,box-shadow]", "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50", className, )} > <input type="email" name="email" required autoComplete="email" aria-label={label ?? placeholder} placeholder={placeholder} onChange={() => status !== "idle" && status !== "pending" && move("idle")} className="h-full min-w-0 flex-1 rounded-full bg-transparent px-4 text-sm text-foreground outline-none placeholder:text-muted-foreground" /> {/* Honeypot: hidden from people and from screen readers, tempting to a bot. */} <input type="text" name="company" tabIndex={-1} autoComplete="off" aria-hidden className="sr-only" /> <button type="submit" disabled={status === "pending"} aria-busy={status === "pending"} aria-label={submitLabel} className="grid size-9 shrink-0 cursor-pointer place-items-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:cursor-default disabled:opacity-80" > {status === "pending" ? ( <span aria-hidden className="size-4 animate-spin rounded-full [background:conic-gradient(from_0deg,transparent_7deg,currentColor_342deg)] [mask:radial-gradient(farthest-side,transparent_calc(100%-2px),black_calc(100%-2px))]" /> ) : status === "success" ? ( <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="size-4"> <path d="M3.5 8.5l3 3 6-6.5" /> </svg> ) : ( <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="size-4"> <path d="M4.5 11.5l7-7M5.5 4.5h6v6" /> </svg> )} </button> <p aria-live="polite" className="sr-only"> {status === "success" ? successMessage : status === "error" ? errorMessage : ""} </p> </form> ); }
Usage
import { SubscribeInput } from "@/components/ui/subscribe-input";
<SubscribeInput onSubmit={async (email) => { await subscribe(email); }} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| onSubmit | (email: string) => void | Promise<void> | — | Does the work — a server action, a fetch, anything. Throwing means failure. |
| placeholder | string | "Your email" | Placeholder, and the field's accessible name unless `label` is given. |
| label | string | — | Accessible name of the field. |
| submitLabel | string | "Subscribe" | Accessible name of the round button. |
| successMessage | string | — | Announced when it worked. |
| errorMessage | string | — | Announced when it failed. |
| onStatusChange | (status) => void | — | Follows "idle" → "pending" → "success" | "error", for a message of your own. |
- The button carries the result: a spinner while sending, a check on success (the field clears), the arrow again on failure (the field keeps what was typed).
- Every state is announced through a polite live region; typing again returns the button to its arrow.
- A hidden honeypot field drops bots that fill everything.
- The spinner keeps spinning with reduced motion on purpose — it is the only sign that something is happening.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called SubscribeInput (no animation library). - A pill: h-12, rounded-full, 1px border, bg-background, focus-within:border-ring and ring-[3px] ring-ring/50, holding an e-mail input (flex-1, text-sm, transparent, no outline) and a 36px round submit button (bg-primary text-primary-foreground). - The button shows the state: while sending, a spinner made of a conic-gradient circle with a radial mask (animate-spin); on success, a check; otherwise an up-right arrow. Disabled and aria-busy while sending. - onSubmit(email) may return a promise; resolving is success (reset the form), throwing is failure (keep the value). Typing again returns the state to idle. - A visually hidden honeypot input named "company" (tabIndex -1, aria-hidden): if it is filled, do nothing. - A visually hidden aria-live="polite" paragraph carries the success or error message.











