Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:31Get Lifetime →
loopkit
freeNew143 lines · framer-motion

Waitlist Form

Leave an email, get a clean 'you're in'. A centred call-to-action card with a single email field for joining a waitlist: inline validation on submit, and a styled success state that replaces the form entirely — no network call, no invented queue position.

Use it for
A pre-launch or capacity-gated product page where the only action available yet is 'leave your email', without pretending there's a live signup count behind it.
Themes
Light + dark
License
Commercial use
Live

Early access

Get on the list

We're letting workspaces in gradually. Leave your email and we'll send an invite.

tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

export interface WaitlistFormProps {
  eyebrow?: string;
  title?: string;
  subcopy?: string;
  placeholder?: string;
  buttonLabel?: string;
  successTitle?: string;
  successBody?: string;
  className?: string;
}

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

export function WaitlistForm({
  eyebrow = "Early access",
  title = "Get on the list",
  subcopy = "We're letting workspaces in gradually. Leave your email and we'll send an invite.",
  placeholder = "[email protected]",
  buttonLabel = "Join the waitlist",
  successTitle = "You're on the list",
  successBody = "We'll email you the moment a spot opens up. No spam in the meantime.",
  className,
}: WaitlistFormProps) {
  const reduce = useReducedMotionSafe();
  const [email, setEmail] = React.useState("");
  const [error, setError] = React.useState<string | null>(null);
  const [status, setStatus] = React.useState<"idle" | "submitting" | "success">("idle");
  const inputId = React.useId();
  const errorId = React.useId();
  const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);

  React.useEffect(() => () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
  }, []);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const trimmed = email.trim();
    if (!trimmed) {
      setError("Enter your email address.");
      return;
    }
    if (!EMAIL_RE.test(trimmed)) {
      setError("That doesn't look like a valid email.");
      return;
    }
    setError(null);
    setStatus("submitting");
    timeoutRef.current = setTimeout(() => setStatus("success"), 500);
  };

  return (
    <section className={cn("w-full bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
      <div className="mx-auto max-w-xl overflow-hidden rounded-[2rem] border-2 border-foreground bg-card p-8 text-center shadow-[10px_10px_0_0_var(--signal)] sm:p-12">
        <AnimatePresence mode="wait">
          {status !== "success" ? (
            <motion.div key="form" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={reduce ? undefined : { opacity: 0 }} transition={{ duration: 0.2 }}>
              <p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
              <h2 className="mt-3 font-display text-[clamp(1.8rem,4.5vw,3rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">{title}</h2>
              <p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">{subcopy}</p>

              <form
                noValidate
                onSubmit={handleSubmit}
                className="mx-auto mt-7 flex max-w-sm flex-col items-stretch gap-3 sm:flex-row"
              >
                <div className="flex-1 text-left">
                  <label htmlFor={inputId} className="sr-only">
                    Email address
                  </label>
                  <input
                    id={inputId}
                    type="email"
                    value={email}
                    onChange={(e) => {
                      setEmail(e.target.value);
                      if (error) setError(null);
                    }}
                    placeholder={placeholder}
                    aria-invalid={!!error}
                    aria-describedby={error ? errorId : undefined}
                    className={cn(
                      "h-12 w-full rounded-full border-2 bg-background px-5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
                      error ? "border-destructive" : "border-border focus-visible:border-foreground",
                    )}
                  />
                </div>
                <button
                  type="submit"
                  disabled={status === "submitting"}
                  className="inline-flex h-12 shrink-0 items-center justify-center rounded-full bg-primary px-6 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-60"
                >
                  {status === "submitting" ? "Joining…" : buttonLabel}
                </button>
              </form>
              <p id={errorId} className="mt-2.5 min-h-[1.1rem] text-sm font-medium text-destructive" aria-live="polite">
                {error ?? ""}
              </p>
            </motion.div>
          ) : (
            <motion.div
              key="success"
              initial={reduce ? false : { opacity: 0, scale: 0.96 }}
              animate={{ opacity: 1, scale: 1 }}
              transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
              role="status"
              aria-live="polite"
            >
              <motion.span
                initial={reduce ? false : { scale: 0.6, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                transition={{ type: "spring", stiffness: 300, damping: 18, delay: 0.05 }}
                className="mx-auto grid size-14 place-items-center rounded-full bg-ok"
              >
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="size-7 text-background">
                  <path d="M5 13l4 4L19 7" />
                </svg>
              </motion.span>
              <h3 className="mt-5 font-display text-2xl font-bold tracking-tight text-foreground">{successTitle}</h3>
              <p className="mx-auto mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">{successBody}</p>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

export default WaitlistForm;

How it behaves

  • →Submitting with an empty or malformed email shows an inline error under the field and never proceeds.
  • →A valid submission shows a brief 'joining' state on the button, then cross-fades the whole form out for a success panel with a checkmark and confirmation copy.
  • →No request is ever sent; the 'submitting' delay is a short local timeout purely so the state change is visible.
  • →The form-to-success cross-fade is skipped under prefers-reduced-motion.

Accessibility

  • ✓The email field has a real (visually hidden) `<label>`, and the error message is tied to it with `aria-describedby` plus `aria-invalid`.
  • ✓The success panel has `role="status"` and `aria-live="polite"` so screen reader users hear the confirmation without extra navigation.
  • ✓Honours prefers-reduced-motion.