Skip to content

Join a waitlist, watch your place roll in, then share a referral link.

Marketingmotion@base-ui/react@number-flow/react

01Preview

Ledger is in private beta

Bookkeeping that closes the month for you. We invite teams in the order they join.

8,214 teams are waiting. No spam, one email when it’s your turn.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react @number-flow/react

03Usage

import { WaitlistForm } from "@/components/ui/waitlist-form";

<WaitlistForm
  hint="8,214 teams are waiting. One email when it’s your turn."
  reward="Each team that joins with your link moves you up 50 places."
  onJoin={async (email) => {
    const res = await fetch("/api/waitlist", { method: "POST", body: JSON.stringify({ email }) });
    if (!res.ok) throw new Error("Couldn’t add you to the list. Try again.");
    return res.json(); // { position: 8215, referralUrl: "https://ledger.app/r/dana-k2f9" }
  }}
/>

04Source

"use client";
import { Button } from "@base-ui/react/button";
import { Field } from "@base-ui/react/field";
import { Form } from "@base-ui/react/form";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { CopyButton } from "@/components/ui/copy-button";
import { cn } from "@/lib/cn";
import { Alert, ArrowUp, Link, Loader, Share } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";

export type WaitlistEntry = {
  email: string;
  /** 1-based place in line. */
  position: number;
  /** The link that credits this person when someone joins through it. */
  referralUrl: string;
};

const EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)*\.[^\s@.]{2,}$/;
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

/** Follows an element's height so the wrapper can animate between two contents of different size. */
function useHeight<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [height, setHeight] = useState<number | "auto">("auto");
  useIsoLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return [ref, height] as const;
}

export type WaitlistFormProps = Omit<React.ComponentProps<"div">, "onSubmit" | "children"> & {
  /** Adds the address to the list and returns its place and referral link. Throw with a message to show it under the field. */
  onJoin: (email: string) => Promise<Omit<WaitlistEntry, "email">>;
  /** Start already on the list, for a returning visitor. */
  defaultEntry?: WaitlistEntry;
  /** A live place in line (from polling or a socket). Moving up shows how many places they gained. */
  position?: number;
  /** Friends who joined through the link. */
  referrals?: number;
  /** How to call the people who join through the link, singular and plural. */
  referralNoun?: [string, string];
  /** What a referral is worth, in one line: "Each friend who joins moves you up 50 places." */
  reward?: React.ReactNode;
  /** One quiet line under the field before joining. */
  hint?: React.ReactNode;
  label?: string;
  placeholder?: string;
  submitLabel?: string;
  /** Title of the share block and the native share sheet. */
  shareTitle?: string;
  /** Locale for the numbers. Fixed, so the server and the browser print the same thing. */
  locale?: string;
  disabled?: boolean;
  onJoined?: (entry: WaitlistEntry) => void;
};

/**
 * Join the waitlist, then see your place: the number rolls up to where you
 * stand, the share link sits underneath with a copy button, and every place
 * gained through a referral rolls the number down with a "+N" beside it.
 */
export function WaitlistForm({
  onJoin,
  defaultEntry,
  position: positionProp,
  referrals = 0,
  referralNoun = ["friend", "friends"],
  reward = "Each friend who joins with your link moves you up the line.",
  hint,
  label = "Email address",
  placeholder = "you@company.com",
  submitLabel = "Join waitlist",
  shareTitle = "Move up the line",
  locale = "en-US",
  disabled = false,
  onJoined,
  className,
  ...rest
}: WaitlistFormProps) {
  const reduce = useReducedMotion();
  const [entry, setEntry] = useState<WaitlistEntry | null>(defaultEntry ?? null);
  // Only a join that just happened rolls the number up from zero; a returning visitor sees it settled.
  const [fresh, setFresh] = useState(false);
  const [email, setEmail] = useState("");
  const [busy, setBusy] = useState(false);
  const [slow, setSlow] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [inner, height] = useHeight<HTMLDivElement>();
  const [resizing, setResizing] = useState(false);
  const alive = useRef(true);

  useEffect(() => {
    alive.current = true;
    return () => {
      alive.current = false;
    };
  }, []);

  useEffect(() => {
    if (!busy) return;
    const t = window.setTimeout(() => setSlow(true), 150);
    return () => {
      window.clearTimeout(t);
      setSlow(false);
    };
  }, [busy]);

  const join = async (value: string) => {
    if (busy) return;
    setBusy(true);
    setErrors({});
    const started = performance.now();
    const settle = async () => {
      const elapsed = performance.now() - started;
      if (elapsed > 150 && elapsed < 450) await wait(450 - elapsed);
    };
    try {
      const result = await onJoin(value);
      await settle();
      if (!alive.current) return;
      const next = { email: value, ...result };
      setFresh(true);
      setEntry(next);
      onJoined?.(next);
    } catch (error) {
      await settle();
      if (!alive.current) return;
      setErrors({ email: error instanceof Error && error.message ? error.message : "Couldn’t add you to the list. Try again." });
    } finally {
      if (alive.current) setBusy(false);
    }
  };

  return (
    <div data-slot="waitlist-form" data-state={entry ? "joined" : busy ? "submitting" : "idle"} className={cn("w-full min-w-0", className)} {...rest}>
    <motion.div
      initial={false}
      animate={{ height }}
      transition={reduce ? { duration: 0 } : { duration: 0.38, ease: ease.inOut }}
      onAnimationStart={() => setResizing(true)}
      onAnimationComplete={() => setResizing(false)}
      // Clip only while the height is changing, so the card's shadow and focus rings are never cut at rest.
      style={{ overflow: resizing ? "clip" : undefined, overflowClipMargin: 8 }}
    >
      <div ref={inner}>
        <AnimatePresence mode="popLayout" initial={false}>
          {entry ? (
            <motion.div
              key="joined"
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              transition={{ duration: reduce ? 0.15 : 0.4, ease: ease.out, delay: reduce ? 0 : 0.08 }}
            >
              <Joined
                entry={entry}
                position={positionProp ?? entry.position}
                referrals={referrals}
                noun={referralNoun}
                reward={reward}
                shareTitle={shareTitle}
                locale={locale}
                roll={fresh}
              />
            </motion.div>
          ) : (
            <motion.div
              key="form"
              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -4, filter: "blur(2px)", transition: { duration: 0.14, ease: ease.in } }}
            >
              <Form errors={errors} onFormSubmit={(v) => join(String(v.email ?? "").trim())} aria-busy={busy || undefined}>
                <Field.Root
                  name="email"
                  disabled={disabled}
                  validate={(v) => {
                    const s = String(v ?? "").trim();
                    if (!s) return "Enter your email address";
                    if (!EMAIL.test(s)) return "Enter a valid email, like name@company.com";
                    return null;
                  }}
                  className="group/wl flex flex-col gap-2"
                >
                  <Field.Label className="sr-only">{label}</Field.Label>
                  <div
                    className={cn(
                      "relative flex h-10 w-full min-w-0 items-center gap-2 rounded-lg border border-line-2 bg-raised pl-3 pr-[3px] shadow-[var(--shadow)]",
                      "transition-[border-color,box-shadow] duration-150 ease-out",
                      "hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3",
                      "group-data-invalid/wl:border-danger/70 group-data-invalid/wl:hover:border-danger group-data-invalid/wl:focus-within:border-danger group-data-invalid/wl:hover:focus-within:border-danger group-data-invalid/wl:focus-within:ring-danger/15",
                      "group-data-disabled/wl:opacity-50 group-data-disabled/wl:shadow-none",
                    )}
                  >
                    <Field.Control
                      type="text"
                      inputMode="email"
                      autoComplete="email"
                      autoCapitalize="none"
                      autoCorrect="off"
                      spellCheck={false}
                      enterKeyHint="go"
                      placeholder={placeholder}
                      value={email}
                      onValueChange={setEmail}
                      readOnly={busy}
                      className={cn(
                        "h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]",
                        "transition-colors duration-150 read-only:text-fg-2 disabled:cursor-not-allowed",
                        "autofill:shadow-[inset_0_0_0_1000px_var(--raised)] autofill:[-webkit-text-fill-color:var(--fg)]",
                      )}
                    />
                    <Button
                      type="submit"
                      disabled={disabled}
                      aria-disabled={busy || undefined}
                      data-busy={busy || undefined}
                      className={cn(
                        "relative inline-flex h-8 shrink-0 select-none items-center justify-center rounded-md bg-fg px-3 text-[12.5px] font-medium tracking-[-0.005em] text-frame",
                        "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
                        "transition-[background-color,scale] duration-150 ease-out hover:bg-fg/90 active:scale-[0.97] active:duration-75",
                        "data-busy:pointer-events-none data-disabled:pointer-events-none",
                        "before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden",
                      )}
                    >
                      <span className="grid place-items-center">
                        <span className={cn("col-start-1 row-start-1 transition-[opacity,filter] duration-150 ease-out", busy && "opacity-60", slow && "opacity-0 blur-[2px] motion-reduce:blur-none")}>
                          {submitLabel}
                        </span>
                        <span aria-hidden className={cn("col-start-1 row-start-1 grid place-items-center transition-[opacity,scale] duration-200 ease-out-expo", slow ? "scale-100 opacity-100" : "scale-75 opacity-0")}>
                          <Loader size={14} className={cn(slow && "animate-spin motion-reduce:animate-spin-slow")} />
                        </span>
                      </span>
                      {busy && <span className="sr-only">Joining</span>}
                    </Button>
                  </div>

                  <div className="grid min-h-[18px] text-[12px] leading-[18px]">
                    {hint != null && (
                      <Field.Description className="col-start-1 row-start-1 text-pretty text-fg-3 transition-opacity duration-150 group-data-invalid/wl:opacity-0">
                        {hint}
                      </Field.Description>
                    )}
                    <Field.Error
                      className={cn(
                        "col-start-1 row-start-1 flex items-start gap-1.5 text-danger transition-[opacity,translate] duration-200 ease-out-expo",
                        "data-starting-style:-translate-y-1 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100 motion-reduce:translate-y-0",
                      )}
                      render={({ children, ...props }) => (
                        <div {...props}>
                          <Alert size={14} className="mt-0.5 shrink-0" />
                          <span className="min-w-0 text-pretty">{children}</span>
                        </div>
                      )}
                    />
                  </div>
                </Field.Root>
              </Form>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </motion.div>
    </div>
  );
}

function Joined({
  entry,
  position,
  referrals,
  noun,
  reward,
  shareTitle,
  locale,
  roll,
}: {
  entry: WaitlistEntry;
  position: number;
  referrals: number;
  noun: [string, string];
  reward: React.ReactNode;
  shareTitle: string;
  locale: string;
  roll: boolean;
}) {
  const reduce = useReducedMotion();
  const card = useRef<HTMLDivElement>(null);
  // A fresh join starts the counter at zero for one frame, then rolls it to the real place.
  const [shown, setShown] = useState(roll ? 0 : position);
  useEffect(() => {
    const id = requestAnimationFrame(() => setShown(position));
    return () => cancelAnimationFrame(id);
  }, [position]);

  // Places gained since the last update, shown for a moment beside the number.
  const [prev, setPrev] = useState(position);
  const [gain, setGain] = useState<{ amount: number; at: number } | null>(null);
  if (position !== prev) {
    setPrev(position);
    if (position < prev) setGain({ amount: prev - position, at: position });
  }
  useEffect(() => {
    if (!gain) return;
    const t = window.setTimeout(() => setGain(null), 2600);
    return () => window.clearTimeout(t);
  }, [gain]);

  const [canShare, setCanShare] = useState(false);
  useEffect(() => {
    const id = requestAnimationFrame(() => setCanShare(typeof navigator !== "undefined" && typeof navigator.share === "function" && matchMedia("(pointer: coarse)").matches));
    return () => cancelAnimationFrame(id);
  }, []);

  // Focus lands on the card when it replaces the form, so the keyboard continues from here.
  useEffect(() => {
    if (roll) card.current?.focus({ preventScroll: true });
  }, [roll]);

  const ahead = Math.max(0, position - 1);
  const fmt = new Intl.NumberFormat(locale);
  const display = entry.referralUrl.replace(/^https?:\/\/(www\.)?/, "");
  const announce = gain
    ? `You moved up ${fmt.format(gain.amount)} ${gain.amount === 1 ? "place" : "places"}. You’re number ${fmt.format(position)} in line.`
    : `You’re on the list. You’re number ${fmt.format(position)} in line.`;

  return (
    <div
      ref={card}
      tabIndex={-1}
      className="flex flex-col rounded-xl border border-line-2 bg-raised shadow-[var(--shadow)] outline-none"
    >
      <div className="flex flex-col gap-1 px-4 pb-4 pt-3.5">
        <p className="flex min-w-0 items-center gap-1.5 text-[12px] text-fg-3">
          <Tick draw={roll && !reduce} />
          <span className="shrink-0 text-fg-2">You’re on the list</span>
          <span aria-hidden className="text-fg-4">·</span>
          <span className="min-w-0 truncate">{entry.email}</span>
        </p>

        {/* NumberFlow pads itself vertically for the spin mask; pull the rows back to the type's rhythm. */}
        <div className="-my-1.5 flex items-center gap-2.5">
          <NumberFlow
            value={shown}
            locales={locale}
            prefix="#"
            aria-hidden
            className="text-[34px] font-medium leading-[40px] tracking-[-0.035em] text-fg"
            transformTiming={{ duration: 900, easing: "cubic-bezier(0.16, 1, 0.3, 1)" }}
            spinTiming={{ duration: 1100, easing: "cubic-bezier(0.16, 1, 0.3, 1)" }}
          />
          <AnimatePresence>
            {gain && (
              <motion.span
                key={gain.at}
                aria-hidden
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.85 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, transition: { duration: 0.16, ease: ease.in } }}
                transition={reduce ? { duration: 0.15 } : { ...spring.pop, delay: 0.25 }}
                className="inline-flex h-6 items-center gap-1 rounded-full bg-success-soft pl-1.5 pr-2 text-[12px] font-medium text-success tabular"
              >
                <ArrowUp size={12} />
                {fmt.format(gain.amount)}
              </motion.span>
            )}
          </AnimatePresence>
        </div>

        <p className="text-[12.5px] text-fg-2 tabular">
          {ahead === 0 ? (
            "You’re first in line."
          ) : (
            <>
              <NumberFlow value={Math.max(0, shown - 1)} locales={locale} aria-hidden className="text-fg" />{" "}
ahead of you
            </>
          )}
        </p>
      </div>

      <div className="flex flex-col gap-3 border-t border-line px-4 pb-4 pt-3.5">
        <div className="flex flex-col gap-0.5">
          <p className="text-[13px] font-medium tracking-[-0.01em] text-fg">{shareTitle}</p>
          <p className="text-pretty text-[12.5px] text-fg-2">{reward}</p>
        </div>

        <div className="flex h-9 min-w-0 items-center gap-2 rounded-lg border border-line bg-frame pl-2.5 pr-1">
          <Link size={14} className="shrink-0 text-fg-3" />
          <span className="min-w-0 flex-1 truncate font-mono text-[12px] text-fg" title={entry.referralUrl}>
            {display}
          </span>
          {canShare && (
            <button
              type="button"
              aria-label="Share link"
              onClick={() => navigator.share({ title: shareTitle, url: entry.referralUrl }).catch(() => {})}
              className={cn(
                "relative grid size-7 shrink-0 place-items-center rounded-md text-fg-2 outline-none",
                "transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92]",
                "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
                "before:absolute before:-inset-2 before:content-['']",
              )}
            >
              <Share size={14} />
            </button>
          )}
          <CopyButton value={entry.referralUrl} size="sm" label="Copy link" copiedLabel="Copied" failedLabel="Failed" />
        </div>

        {referrals > 0 && (
          <p className="text-[12px] text-fg-3 tabular">
            <NumberFlow value={referrals} locales={locale} className="text-fg-2" /> {referrals === 1 ? `${noun[0]} has` : `${noun[1]} have`} joined with your link
          </p>
        )}
      </div>

      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  );
}

/** A small ring that closes, then a tick that draws inside it. */
function Tick({ draw }: { draw: boolean }) {
  const d = (delay: number, duration: number) => (draw ? { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration, ease: ease.out, delay } } : {});
  return (
    <svg width={14} height={14} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden className="shrink-0 text-success">
      <motion.circle cx="8" cy="8" r="6.25" opacity={0.4} {...d(0.15, 0.4)} />
      <motion.path d="m5.25 8.25 1.9 1.9 3.6-4.15" {...d(0.35, 0.3)} />
    </svg>
  );
}

05Props

PropTypeDefaultDescription
onJoin*(email: string) => Promise<{ position: number; referralUrl: string }>Adds a valid, trimmed address and returns its place and link. Throw with a message to show it under the field.
defaultEntry{ email: string; position: number; referralUrl: string }Start on the joined card, for a returning visitor. The number shows settled, without rolling.
positionnumberA live place in line. Moving up rolls the number down and shows the places gained.
referralsnumber0People who joined through the link. The line appears from the first one.
referralNoun[string, string]["friend", "friends"]Singular and plural for the referrals line.
rewardReactNode"Each friend who joins with your link moves you up the line."What a referral is worth, in one line.
hintReactNodeOne quiet line under the field before joining. Errors take its place.
shareTitlestring"Move up the line"Heading of the share block, and the title passed to the native share sheet.
submitLabelstring"Join waitlist"The button’s verb.
labelstring"Email address"Accessible name of the field, visually hidden.
placeholderstring"you@company.com"An example address.
localestring"en-US"Locale for the numbers, fixed so the server and browser agree.
disabledbooleanfalseDisables the field and the button.
onJoined(entry: WaitlistEntry) => voidCalled once the joined card is showing.

06Notes

Behavior

  • The place counts up from zero only on a join that just happened; a returning visitor sees it already settled.
  • A lower position shows the places gained in a chip for 2.6s; a higher one (someone else referred past you) just rolls, without a chip.
  • The spinner appears only after 150ms and then stays at least 300ms. A thrown error keeps the address, refocuses the field and shows the message where the hint was.
  • On touch devices with a native share sheet a Share button sits beside Copy link. The link shows without its protocol and copies in full.

Motion

  • The wrapper animates its height between the form and the card in 380ms on the in-out curve, clipping only while it moves so the card’s shadow is never cut at rest.
  • The form leaves in 140ms, lifting 4px with a 2px blur; the card arrives 8px from below as a 4px blur clears, 400ms on the expo ease-out, while its ring closes and tick draws.
  • Digits roll with NumberFlow over about 1s on the expo curve; the gain chip pops in on a spring 250ms after the number starts. Reduced motion keeps short fades and sets numbers without rolling.

Accessibility

  • A Base UI Form and Field: a hidden label, errors linked with aria-describedby, and focus sent back to the field when joining fails.
  • After joining, focus moves to the card so the next Tab reaches Copy link. A polite live region reads the place, and on each gain says how many places they moved up.
  • The rolling digits are hidden from assistive tech; the live region carries the same numbers as plain text.