{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "waitlist-form",
  "type": "registry:ui",
  "title": "Waitlist Form",
  "description": "Join a waitlist, watch your place roll in, then share a referral link.",
  "categories": [
    "marketing"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/waitlist-form.tsx",
      "content": "\"use client\";\nimport { Button } from \"@base-ui/react/button\";\nimport { Field } from \"@base-ui/react/field\";\nimport { Form } from \"@base-ui/react/form\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, ArrowUp, Link, Loader, Share } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type WaitlistEntry = {\n  email: string;\n  /** 1-based place in line. */\n  position: number;\n  /** The link that credits this person when someone joins through it. */\n  referralUrl: string;\n};\n\nconst EMAIL = /^[^\\s@]+@[^\\s@.]+(\\.[^\\s@.]+)*\\.[^\\s@.]{2,}$/;\nconst wait = (ms: number) => new Promise((r) => setTimeout(r, ms));\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/** Follows an element's height so the wrapper can animate between two contents of different size. */\nfunction useHeight<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useIsoLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, height] as const;\n}\n\nexport type WaitlistFormProps = Omit<React.ComponentProps<\"div\">, \"onSubmit\" | \"children\"> & {\n  /** Adds the address to the list and returns its place and referral link. Throw with a message to show it under the field. */\n  onJoin: (email: string) => Promise<Omit<WaitlistEntry, \"email\">>;\n  /** Start already on the list, for a returning visitor. */\n  defaultEntry?: WaitlistEntry;\n  /** A live place in line (from polling or a socket). Moving up shows how many places they gained. */\n  position?: number;\n  /** Friends who joined through the link. */\n  referrals?: number;\n  /** How to call the people who join through the link, singular and plural. */\n  referralNoun?: [string, string];\n  /** What a referral is worth, in one line: \"Each friend who joins moves you up 50 places.\" */\n  reward?: React.ReactNode;\n  /** One quiet line under the field before joining. */\n  hint?: React.ReactNode;\n  label?: string;\n  placeholder?: string;\n  submitLabel?: string;\n  /** Title of the share block and the native share sheet. */\n  shareTitle?: string;\n  /** Locale for the numbers. Fixed, so the server and the browser print the same thing. */\n  locale?: string;\n  disabled?: boolean;\n  onJoined?: (entry: WaitlistEntry) => void;\n};\n\n/**\n * Join the waitlist, then see your place: the number rolls up to where you\n * stand, the share link sits underneath with a copy button, and every place\n * gained through a referral rolls the number down with a \"+N\" beside it.\n */\nexport function WaitlistForm({\n  onJoin,\n  defaultEntry,\n  position: positionProp,\n  referrals = 0,\n  referralNoun = [\"friend\", \"friends\"],\n  reward = \"Each friend who joins with your link moves you up the line.\",\n  hint,\n  label = \"Email address\",\n  placeholder = \"you@company.com\",\n  submitLabel = \"Join waitlist\",\n  shareTitle = \"Move up the line\",\n  locale = \"en-US\",\n  disabled = false,\n  onJoined,\n  className,\n  ...rest\n}: WaitlistFormProps) {\n  const reduce = useReducedMotion();\n  const [entry, setEntry] = useState<WaitlistEntry | null>(defaultEntry ?? null);\n  // Only a join that just happened rolls the number up from zero; a returning visitor sees it settled.\n  const [fresh, setFresh] = useState(false);\n  const [email, setEmail] = useState(\"\");\n  const [busy, setBusy] = useState(false);\n  const [slow, setSlow] = useState(false);\n  const [errors, setErrors] = useState<Record<string, string>>({});\n  const [inner, height] = useHeight<HTMLDivElement>();\n  const [resizing, setResizing] = useState(false);\n  const alive = useRef(true);\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!busy) return;\n    const t = window.setTimeout(() => setSlow(true), 150);\n    return () => {\n      window.clearTimeout(t);\n      setSlow(false);\n    };\n  }, [busy]);\n\n  const join = async (value: string) => {\n    if (busy) return;\n    setBusy(true);\n    setErrors({});\n    const started = performance.now();\n    const settle = async () => {\n      const elapsed = performance.now() - started;\n      if (elapsed > 150 && elapsed < 450) await wait(450 - elapsed);\n    };\n    try {\n      const result = await onJoin(value);\n      await settle();\n      if (!alive.current) return;\n      const next = { email: value, ...result };\n      setFresh(true);\n      setEntry(next);\n      onJoined?.(next);\n    } catch (error) {\n      await settle();\n      if (!alive.current) return;\n      setErrors({ email: error instanceof Error && error.message ? error.message : \"Couldn’t add you to the list. Try again.\" });\n    } finally {\n      if (alive.current) setBusy(false);\n    }\n  };\n\n  return (\n    <div data-slot=\"waitlist-form\" data-state={entry ? \"joined\" : busy ? \"submitting\" : \"idle\"} className={cn(\"w-full min-w-0\", className)} {...rest}>\n    <motion.div\n      initial={false}\n      animate={{ height }}\n      transition={reduce ? { duration: 0 } : { duration: 0.38, ease: ease.inOut }}\n      onAnimationStart={() => setResizing(true)}\n      onAnimationComplete={() => setResizing(false)}\n      // Clip only while the height is changing, so the card's shadow and focus rings are never cut at rest.\n      style={{ overflow: resizing ? \"clip\" : undefined, overflowClipMargin: 8 }}\n    >\n      <div ref={inner}>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {entry ? (\n            <motion.div\n              key=\"joined\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              transition={{ duration: reduce ? 0.15 : 0.4, ease: ease.out, delay: reduce ? 0 : 0.08 }}\n            >\n              <Joined\n                entry={entry}\n                position={positionProp ?? entry.position}\n                referrals={referrals}\n                noun={referralNoun}\n                reward={reward}\n                shareTitle={shareTitle}\n                locale={locale}\n                roll={fresh}\n              />\n            </motion.div>\n          ) : (\n            <motion.div\n              key=\"form\"\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n            >\n              <Form errors={errors} onFormSubmit={(v) => join(String(v.email ?? \"\").trim())} aria-busy={busy || undefined}>\n                <Field.Root\n                  name=\"email\"\n                  disabled={disabled}\n                  validate={(v) => {\n                    const s = String(v ?? \"\").trim();\n                    if (!s) return \"Enter your email address\";\n                    if (!EMAIL.test(s)) return \"Enter a valid email, like name@company.com\";\n                    return null;\n                  }}\n                  className=\"group/wl flex flex-col gap-2\"\n                >\n                  <Field.Label className=\"sr-only\">{label}</Field.Label>\n                  <div\n                    className={cn(\n                      \"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)]\",\n                      \"transition-[border-color,box-shadow] duration-150 ease-out\",\n                      \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3\",\n                      \"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\",\n                      \"group-data-disabled/wl:opacity-50 group-data-disabled/wl:shadow-none\",\n                    )}\n                  >\n                    <Field.Control\n                      type=\"text\"\n                      inputMode=\"email\"\n                      autoComplete=\"email\"\n                      autoCapitalize=\"none\"\n                      autoCorrect=\"off\"\n                      spellCheck={false}\n                      enterKeyHint=\"go\"\n                      placeholder={placeholder}\n                      value={email}\n                      onValueChange={setEmail}\n                      readOnly={busy}\n                      className={cn(\n                        \"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\",\n                        \"transition-colors duration-150 read-only:text-fg-2 disabled:cursor-not-allowed\",\n                        \"autofill:shadow-[inset_0_0_0_1000px_var(--raised)] autofill:[-webkit-text-fill-color:var(--fg)]\",\n                      )}\n                    />\n                    <Button\n                      type=\"submit\"\n                      disabled={disabled}\n                      aria-disabled={busy || undefined}\n                      data-busy={busy || undefined}\n                      className={cn(\n                        \"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\",\n                        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                        \"transition-[background-color,scale] duration-150 ease-out hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n                        \"data-busy:pointer-events-none data-disabled:pointer-events-none\",\n                        \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n                      )}\n                    >\n                      <span className=\"grid place-items-center\">\n                        <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\")}>\n                          {submitLabel}\n                        </span>\n                        <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\")}>\n                          <Loader size={14} className={cn(slow && \"animate-spin motion-reduce:animate-spin-slow\")} />\n                        </span>\n                      </span>\n                      {busy && <span className=\"sr-only\">Joining</span>}\n                    </Button>\n                  </div>\n\n                  <div className=\"grid min-h-[18px] text-[12px] leading-[18px]\">\n                    {hint != null && (\n                      <Field.Description className=\"col-start-1 row-start-1 text-pretty text-fg-3 transition-opacity duration-150 group-data-invalid/wl:opacity-0\">\n                        {hint}\n                      </Field.Description>\n                    )}\n                    <Field.Error\n                      className={cn(\n                        \"col-start-1 row-start-1 flex items-start gap-1.5 text-danger transition-[opacity,translate] duration-200 ease-out-expo\",\n                        \"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\",\n                      )}\n                      render={({ children, ...props }) => (\n                        <div {...props}>\n                          <Alert size={14} className=\"mt-0.5 shrink-0\" />\n                          <span className=\"min-w-0 text-pretty\">{children}</span>\n                        </div>\n                      )}\n                    />\n                  </div>\n                </Field.Root>\n              </Form>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </motion.div>\n    </div>\n  );\n}\n\nfunction Joined({\n  entry,\n  position,\n  referrals,\n  noun,\n  reward,\n  shareTitle,\n  locale,\n  roll,\n}: {\n  entry: WaitlistEntry;\n  position: number;\n  referrals: number;\n  noun: [string, string];\n  reward: React.ReactNode;\n  shareTitle: string;\n  locale: string;\n  roll: boolean;\n}) {\n  const reduce = useReducedMotion();\n  const card = useRef<HTMLDivElement>(null);\n  // A fresh join starts the counter at zero for one frame, then rolls it to the real place.\n  const [shown, setShown] = useState(roll ? 0 : position);\n  useEffect(() => {\n    const id = requestAnimationFrame(() => setShown(position));\n    return () => cancelAnimationFrame(id);\n  }, [position]);\n\n  // Places gained since the last update, shown for a moment beside the number.\n  const [prev, setPrev] = useState(position);\n  const [gain, setGain] = useState<{ amount: number; at: number } | null>(null);\n  if (position !== prev) {\n    setPrev(position);\n    if (position < prev) setGain({ amount: prev - position, at: position });\n  }\n  useEffect(() => {\n    if (!gain) return;\n    const t = window.setTimeout(() => setGain(null), 2600);\n    return () => window.clearTimeout(t);\n  }, [gain]);\n\n  const [canShare, setCanShare] = useState(false);\n  useEffect(() => {\n    const id = requestAnimationFrame(() => setCanShare(typeof navigator !== \"undefined\" && typeof navigator.share === \"function\" && matchMedia(\"(pointer: coarse)\").matches));\n    return () => cancelAnimationFrame(id);\n  }, []);\n\n  // Focus lands on the card when it replaces the form, so the keyboard continues from here.\n  useEffect(() => {\n    if (roll) card.current?.focus({ preventScroll: true });\n  }, [roll]);\n\n  const ahead = Math.max(0, position - 1);\n  const fmt = new Intl.NumberFormat(locale);\n  const display = entry.referralUrl.replace(/^https?:\\/\\/(www\\.)?/, \"\");\n  const announce = gain\n    ? `You moved up ${fmt.format(gain.amount)} ${gain.amount === 1 ? \"place\" : \"places\"}. You’re number ${fmt.format(position)} in line.`\n    : `You’re on the list. You’re number ${fmt.format(position)} in line.`;\n\n  return (\n    <div\n      ref={card}\n      tabIndex={-1}\n      className=\"flex flex-col rounded-xl border border-line-2 bg-raised shadow-[var(--shadow)] outline-none\"\n    >\n      <div className=\"flex flex-col gap-1 px-4 pb-4 pt-3.5\">\n        <p className=\"flex min-w-0 items-center gap-1.5 text-[12px] text-fg-3\">\n          <Tick draw={roll && !reduce} />\n          <span className=\"shrink-0 text-fg-2\">You’re on the list</span>\n          <span aria-hidden className=\"text-fg-4\">·</span>\n          <span className=\"min-w-0 truncate\">{entry.email}</span>\n        </p>\n\n        {/* NumberFlow pads itself vertically for the spin mask; pull the rows back to the type's rhythm. */}\n        <div className=\"-my-1.5 flex items-center gap-2.5\">\n          <NumberFlow\n            value={shown}\n            locales={locale}\n            prefix=\"#\"\n            aria-hidden\n            className=\"text-[34px] font-medium leading-[40px] tracking-[-0.035em] text-fg\"\n            transformTiming={{ duration: 900, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" }}\n            spinTiming={{ duration: 1100, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" }}\n          />\n          <AnimatePresence>\n            {gain && (\n              <motion.span\n                key={gain.at}\n                aria-hidden\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.85 }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, transition: { duration: 0.16, ease: ease.in } }}\n                transition={reduce ? { duration: 0.15 } : { ...spring.pop, delay: 0.25 }}\n                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\"\n              >\n                <ArrowUp size={12} />\n                {fmt.format(gain.amount)}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <p className=\"text-[12.5px] text-fg-2 tabular\">\n          {ahead === 0 ? (\n            \"You’re first in line.\"\n          ) : (\n            <>\n              <NumberFlow value={Math.max(0, shown - 1)} locales={locale} aria-hidden className=\"text-fg\" />{\" \"}\nahead of you\n            </>\n          )}\n        </p>\n      </div>\n\n      <div className=\"flex flex-col gap-3 border-t border-line px-4 pb-4 pt-3.5\">\n        <div className=\"flex flex-col gap-0.5\">\n          <p className=\"text-[13px] font-medium tracking-[-0.01em] text-fg\">{shareTitle}</p>\n          <p className=\"text-pretty text-[12.5px] text-fg-2\">{reward}</p>\n        </div>\n\n        <div className=\"flex h-9 min-w-0 items-center gap-2 rounded-lg border border-line bg-frame pl-2.5 pr-1\">\n          <Link size={14} className=\"shrink-0 text-fg-3\" />\n          <span className=\"min-w-0 flex-1 truncate font-mono text-[12px] text-fg\" title={entry.referralUrl}>\n            {display}\n          </span>\n          {canShare && (\n            <button\n              type=\"button\"\n              aria-label=\"Share link\"\n              onClick={() => navigator.share({ title: shareTitle, url: entry.referralUrl }).catch(() => {})}\n              className={cn(\n                \"relative grid size-7 shrink-0 place-items-center rounded-md text-fg-2 outline-none\",\n                \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92]\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                \"before:absolute before:-inset-2 before:content-['']\",\n              )}\n            >\n              <Share size={14} />\n            </button>\n          )}\n          <CopyButton value={entry.referralUrl} size=\"sm\" label=\"Copy link\" copiedLabel=\"Copied\" failedLabel=\"Failed\" />\n        </div>\n\n        {referrals > 0 && (\n          <p className=\"text-[12px] text-fg-3 tabular\">\n            <NumberFlow value={referrals} locales={locale} className=\"text-fg-2\" /> {referrals === 1 ? `${noun[0]} has` : `${noun[1]} have`} joined with your link\n          </p>\n        )}\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\n/** A small ring that closes, then a tick that draws inside it. */\nfunction Tick({ draw }: { draw: boolean }) {\n  const d = (delay: number, duration: number) => (draw ? { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration, ease: ease.out, delay } } : {});\n  return (\n    <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\">\n      <motion.circle cx=\"8\" cy=\"8\" r=\"6.25\" opacity={0.4} {...d(0.15, 0.4)} />\n      <motion.path d=\"m5.25 8.25 1.9 1.9 3.6-4.15\" {...d(0.35, 0.3)} />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/waitlist-form"
}
