Waitlist field
An email field that becomes its own answer: the button stretches across it, then settles as "You’re on the list · #1,248".
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/waitlist-field.jsonOverview
The first thing a launch page asks for, and usually a form that blinks and shows a green line. Here it is one surface. Submit and the button stretches across the whole field while a light sweeps through it. When the answer comes back it settles in place: a check draws itself, the label morphs to "You’re on the list", and the place number rolls up into view. A bad email gets a small shake and a hint underneath; a failed request folds the button back with "Try again".
States
- idle
- Email and "Join the waitlist" in one hairline pill; the button’s width is measured so the stretch starts exactly from it.
- invalid
- A 380ms shake, the pill’s edge tints red and "Enter a full email address" slides in below. Typing clears it.
- joining
- The button stretches across the field (560ms) and a light band sweeps through it; the label morphs to "Joining".
- joined
- A check draws itself, the label morphs to "You’re on the list" and the place rolls up to #1,248.
- failed
- The button folds back to its own width with a shake and "Try again".
Usage
import { WaitlistField } from "@/components/waitlist-field";
<WaitlistField onSubmit={(email) => joinWaitlist(email)} />Works with
With your API
Return a promise from onSubmit; resolve with the place in line to show it, throw to show Try again.
"use client";
import { WaitlistField } from "@/components/waitlist-field";
export function Hero() {
return (
<WaitlistField
onSubmit={async (email) => {
const res = await fetch("/api/waitlist", { method: "POST", body: JSON.stringify({ email }) });
if (!res.ok) throw new Error("Couldn't join");
const { position } = await res.json();
return { position };
}}
/>
);
}
// app/api/waitlist/route.ts
export async function POST(req: Request) {
const { email } = await req.json();
const position = await db.waitlist.add(email); // your storage; returns their place
return Response.json({ position });
}Props
| Prop | Type | Description |
|---|---|---|
| onSubmit | (email) => Promise<{ position? } | void> | Runs on a valid email. Resolve to show the joined state, throw to show Try again. |
| joined | { position? } | null | Show the joined state straight away, e.g. for someone already on the list. |
| placeholder | stringdefault "you@company.com" | The field’s placeholder. |
| labels | Partial<{ idle; pending; success; error; invalid }> | Wording for each state and the invalid hint. |
Accessibility and motion
- A real form: Enter submits, the field has a label, autocomplete="email" and the right mobile keyboard.
- The hint is linked to the field with aria-describedby, and joining or failing is announced from a status region.
- Installs Number roll and Text morph alongside it.
- With reduced motion there is no shake or sweep; states change in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Number roll, Text morph.
Source
›Show the full source of waitlist-field.tsx (206 lines)
"use client";
import * as React from "react";
import { NumberRoll } from "./number-roll";
import { TextMorph } from "./text-morph";
/* ─────────────────────────────────────────────────────────
* WAITLIST FIELD: an email field that becomes its own answer
*
* idle email and "Join the waitlist" in one pill
* invalid the pill gives a small shake, its edge tints and
* a hint fades in underneath
* joining the button stretches across the whole field and
* a light sweeps through it while the request runs
* joined "You're on the list · #1,248": a check draws
* itself and the place number rolls up into view
* failed the button folds back and asks you to try again
*
* Give it an onSubmit that returns a promise (resolve with
* { position } to show the place in line).
* ───────────────────────────────────────────────────────── */
export interface WaitlistResult {
position?: number;
}
export interface WaitlistFieldProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit"> {
onSubmit: (email: string) => Promise<WaitlistResult | void> | WaitlistResult | void;
/** Show the joined state straight away, e.g. for someone already on the list. */
joined?: WaitlistResult | null;
placeholder?: string;
labels?: Partial<{ idle: string; pending: string; success: string; error: string; invalid: string }>;
}
type Status = "idle" | "pending" | "success" | "error";
const LABELS = {
idle: "Join the waitlist",
pending: "Joining",
success: "You’re on the list",
error: "Try again",
invalid: "Enter a full email address",
};
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
const shake = (el: HTMLElement | null) =>
el?.animate(
[{ transform: "none" }, { transform: "translateX(-5px)" }, { transform: "translateX(5px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],
{ duration: 380, easing: "ease-out" }
);
export function WaitlistField({ onSubmit, joined, placeholder = "you@company.com", labels, className = "", ...props }: WaitlistFieldProps) {
const reduced = useReducedMotion();
const text = { ...LABELS, ...labels };
const id = React.useId();
const [status, setStatus] = React.useState<Status>(joined ? "success" : "idle");
const [position, setPosition] = React.useState<number | undefined>(joined?.position);
const [email, setEmail] = React.useState("");
const [invalid, setInvalid] = React.useState(false);
const [restWidth, setRestWidth] = React.useState(0);
const pillRef = React.useRef<HTMLDivElement>(null);
const measureRef = React.useRef<HTMLSpanElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
// How wide the button is at rest: measured from its label, so the stretch starts exactly from there.
const restLabel = status === "error" ? text.error : text.idle;
React.useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const measure = () => setRestWidth((w) => (w === el.offsetWidth ? w : el.offsetWidth));
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [restLabel]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (status === "pending" || status === "success") return;
const value = email.trim();
if (!EMAIL.test(value)) {
setInvalid(true);
if (!reduced) shake(pillRef.current);
inputRef.current?.focus();
return;
}
setInvalid(false);
setStatus("pending");
try {
const result = await onSubmit(value);
setPosition(result ? result.position : undefined);
setStatus("success");
} catch {
setStatus("error");
if (!reduced) shake(pillRef.current);
}
};
const covering = status === "pending" || status === "success";
const transition = reduced ? "none" : `width 560ms ${EASE}, background-color 300ms ${EASE}`;
return (
<form noValidate onSubmit={submit} className={`w-full ${className}`} {...props}>
<div
ref={pillRef}
className={`relative flex h-12 items-center rounded-full bg-background p-1 transition-[box-shadow] duration-300 ${
invalid ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)]" : "shadow-[inset_0_0_0_1px_var(--border)] focus-within:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_30%,transparent)]"
}`}
>
<label htmlFor={`${id}-email`} className="sr-only">
Email address
</label>
<input
ref={inputRef}
id={`${id}-email`}
type="email"
inputMode="email"
autoComplete="email"
value={email}
disabled={covering}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? `${id}-hint` : undefined}
onChange={(e) => {
setEmail(e.target.value);
if (invalid) setInvalid(false);
}}
placeholder={placeholder}
className="h-full min-w-0 flex-1 bg-transparent pl-4 text-[14px] text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-0"
style={{ paddingRight: restWidth + 12, transition: reduced ? "none" : "opacity 200ms ease-out" }}
/>
{/* The one surface: rests as a button, stretches over the field, settles as the answer. */}
<button
type="submit"
aria-disabled={covering || undefined}
className="absolute bottom-1 right-1 top-1 flex items-center justify-center overflow-hidden rounded-full bg-primary px-4 text-[13px] font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.98]"
style={{ width: covering ? "calc(100% - 8px)" : restWidth || undefined, transition }}
>
{status === "pending" && (
<span aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-full">
<span className="absolute inset-y-0 left-0 w-1/2 bg-gradient-to-r from-transparent via-white/25 to-transparent animate-[ui-scan_1.3s_ease-in-out_infinite] motion-reduce:hidden" />
</span>
)}
<span className="relative flex items-center gap-2 whitespace-nowrap">
<span
aria-hidden="true"
className="flex h-4 items-center justify-center overflow-hidden"
style={{ width: status === "success" ? 16 : 0, transition: reduced ? "none" : `width 380ms ${EASE}` }}
>
<svg viewBox="0 0 16 16" fill="none" className="size-4 shrink-0">
<path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray={1}
style={{ strokeDashoffset: status === "success" ? 0 : 1, transition: status === "success" && !reduced ? `stroke-dashoffset 460ms ${EASE} 260ms` : "none" }}
/>
</svg>
</span>
<TextMorph>{status === "pending" ? text.pending : status === "success" ? text.success : restLabel}</TextMorph>
{status === "success" && position !== undefined && (
<span className="flex items-center gap-2 animate-[ui-fade-in_300ms_ease-out_200ms_both] motion-reduce:animate-none">
<span aria-hidden="true" className="opacity-50">
·
</span>
<span className="tabular-nums">
#<NumberRoll value={position} from={Math.max(0, position - 36)} duration={1100} />
</span>
</span>
)}
</span>
</button>
{/* Measures the resting button, so its width can animate from an exact number. */}
<span ref={measureRef} aria-hidden="true" className="invisible absolute whitespace-nowrap px-4 text-[13px] font-medium">
{restLabel}
</span>
</div>
<p
id={`${id}-hint`}
className="mt-2 h-4 pl-4 text-[12px] text-red-600 transition-[opacity,transform] duration-300 dark:text-red-400"
style={{ opacity: invalid ? 1 : 0, transform: invalid ? "none" : "translateY(-3px)" }}
>
{invalid ? text.invalid : ""}
</p>
<span role="status" className="sr-only">
{status === "success" ? `${text.success}${position !== undefined ? `, number ${position}` : ""}` : status === "error" ? "That didn’t go through. Try again." : ""}
</span>
</form>
);
}
Registry item: /r/waitlist-field.json