{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"waitlist-join","type":"registry:component","title":"Waitlist join","description":"Drops your face into a real queue and rolls out your place in line.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/waitlist-join.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport Image from \"next/image\";\nimport { cn } from \"@/lib/utils\";\n\nexport type QueuePerson = { id: string; name: string; avatar?: string };\n\n// Nine faces fill the strip on a desktop. On a phone the front few slide\n// under the left fade, which reads as the line carrying on.\nconst SLOTS = 9;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// People shuffling along a line: slower than a UI transition so the eye can\n// follow who moved where, with a little settle as each one stops.\nconst SHUFFLE = { type: \"spring\", visualDuration: 0.45, bounce: 0.15 } as const;\n// Your face drops into its slot and lands with a small bounce.\nconst DROP = { type: \"spring\", visualDuration: 0.4, bounce: 0.3 } as const;\n// Long enough to show a real request is in flight, short enough not to wait on.\nconst JOIN_MS = 650;\n\n\nfunction problem(raw: string) {\n  const email = raw.trim();\n  if (!email) return \"Enter your email to join.\";\n  if (/\\s/.test(email)) return \"Emails cannot contain spaces.\";\n  const at = email.lastIndexOf(\"@\");\n  if (at === -1) return \"Add an @ and a domain, like sam@studio.com.\";\n  if (at === 0) return \"Add your name before the @.\";\n  const domain = email.slice(at + 1);\n  if (!domain) return \"Add the domain after the @, like gmail.com.\";\n  if (!/\\.[^.]{2,}$/.test(domain)) {\n    return `Finish the domain, like @${domain.replace(/\\.+$/, \"\")}.com.`;\n  }\n  return \"\";\n}\n\n// A short, stable referral link from the address, no server needed.\nfunction referral(email: string, base: string) {\n  const local = email.split(\"@\")[0].toLowerCase().replace(/[^a-z0-9]/g, \"\");\n  let hash = 0;\n  for (const char of email) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;\n  return `${base}/${local.slice(0, 12) || \"friend\"}-${hash.toString(36).slice(0, 3)}`;\n}\n\nconst format = (n: number) => n.toLocaleString(\"en-US\");\n\n// A slot machine for the position: each digit column starts on 0 and rolls\n// to its value, left to right, the first time it shows. Later changes roll\n// from wherever it was.\nfunction Roll({ value }: { value: number }) {\n  const [armed, setArmed] = useState(false);\n  useEffect(() => {\n    const frame = requestAnimationFrame(() => setArmed(true));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n  const text = format(value);\n  let digit = 0;\n  return (\n    <span aria-hidden className=\"inline-flex tabular-nums\">\n      {[...text].map((char, i) => {\n        const place = text.length - i;\n        if (!/\\d/.test(char)) return <span key={`s${place}`}>{char}</span>;\n        const order = digit++;\n        return (\n          <span\n            key={`d${place}`}\n            // Fades the neighbours peeking in above and below mid-roll; at\n            // rest the digit sits inside the clear middle.\n            className=\"relative inline-block h-[1lh] overflow-hidden [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\"\n          >\n            <span\n              // The reveal happens once, so it can take its time: 700ms plus\n              // an 80ms stagger reads as the number being drawn for you.\n              className=\"flex flex-col transition-[translate] duration-700 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n              style={{\n                translate: `0 ${armed ? -Number(char) * 10 : 0}%`,\n                transitionDelay: `${order * 80}ms`,\n              }}\n            >\n              {Array.from({ length: 10 }, (_, n) => (\n                <span key={n} className=\"h-[1lh]\">\n                  {n}\n                </span>\n              ))}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Face({ person }: { person: QueuePerson }) {\n  return (\n    <span\n      title={person.name}\n      className={cn(\n        // Every face sits on the same light circle in both themes, like a\n        // printed photo, with the faint image outline for an edge.\n        \"flex size-9 items-end justify-center overflow-hidden rounded-full bg-[oklch(0.97_0_0)] shadow-[inset_0_0_0_1px_oklch(0_0_0/0.1)] dark:shadow-[inset_0_0_0_1px_oklch(1_0_0/0.1)]\",\n      )}\n    >\n      {person.avatar ? (\n        <Image\n          src={person.avatar}\n          alt=\"\"\n          width={36}\n          height={36}\n          unoptimized\n          draggable={false}\n          className=\"size-full\"\n        />\n      ) : (\n        // No photo: a plain head and shoulders in the same light circle, so\n        // the strip reads as one set of people rather than faces and\n        // letters. Grey on the fixed light circle, so fixed greys.\n        <svg viewBox=\"0 0 36 36\" aria-hidden className=\"size-full\">\n          <circle cx=\"18\" cy=\"14.5\" r=\"6.5\" fill=\"oklch(0.84 0 0)\" />\n          <path d=\"M5 36c1.5-8 6.5-12 13-12s11.5 4 13 12Z\" fill=\"oklch(0.84 0 0)\" />\n        </svg>\n      )}\n    </span>\n  );\n}\n\nconst fade = {\n  initial: { opacity: 0, y: 4, filter: \"blur(4px)\" },\n  animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: { opacity: 0, y: -2, filter: \"blur(2px)\", transition: { duration: 0.12 } },\n  transition: { duration: 0.24, ease: EASE_OUT },\n} as const;\n\nexport function WaitlistJoin({\n  product = \"Parcel\",\n  waiting,\n  queue,\n  skip = 5,\n  linkBase = \"parcel.so/i\",\n  onJoin,\n  className,\n}: {\n  product?: string;\n  // How many people are already in line.\n  waiting: number;\n  // The last few people in line, front first. Only the tail is shown.\n  queue: QueuePerson[];\n  // Spots a share moves you up.\n  skip?: number;\n  linkBase?: string;\n  onJoin?: (email: string) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const [email, setEmail] = useState(\"\");\n  const [touched, setTouched] = useState(false);\n  const [status, setStatus] = useState<\"idle\" | \"joining\" | \"joined\">(\"idle\");\n  const [bumped, setBumped] = useState(false);\n  const [copied, setCopied] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const copyRef = useRef<HTMLButtonElement>(null);\n  const timers = useRef<number[]>([]);\n\n  useEffect(() => {\n    const pending = timers.current;\n    return () => pending.forEach((t) => window.clearTimeout(t));\n  }, []);\n\n  const error = touched ? problem(email) : \"\";\n  const joined = status === \"joined\";\n  // Your face only stays in view if the skip leaves someone in front of you.\n  const hop = Math.min(skip, SLOTS - 2);\n  const position = waiting + 1 - (bumped ? skip : 0);\n  const me: QueuePerson = {\n    id: \"me\",\n    name: \"You\",\n  };\n\n  const line = joined\n    ? (() => {\n        const rest = queue.slice(-(SLOTS - 1));\n        rest.splice(rest.length - (bumped ? hop : 0), 0, me);\n        return rest;\n      })()\n    : queue.slice(-SLOTS);\n\n  const join = () => {\n    setTouched(true);\n    if (problem(email) || status !== \"idle\") {\n      inputRef.current?.focus();\n      return;\n    }\n    setStatus(\"joining\");\n    timers.current.push(\n      window.setTimeout(() => {\n        setStatus(\"joined\");\n        onJoin?.(email.trim());\n        timers.current.push(\n          window.setTimeout(() => copyRef.current?.focus(), 50),\n        );\n      }, JOIN_MS),\n    );\n  };\n\n  const link = referral(email.trim(), linkBase);\n\n  const copy = () => {\n    navigator.clipboard?.writeText(`https://${link}`).catch(() => {});\n    setCopied(true);\n    setBumped(true);\n    timers.current.push(window.setTimeout(() => setCopied(false), 1800));\n  };\n\n  const reset = () => {\n    setStatus(\"idle\");\n    setBumped(false);\n    setCopied(false);\n    setTouched(false);\n    setEmail(\"\");\n    timers.current.push(\n      window.setTimeout(() => inputRef.current?.focus(), 50),\n    );\n  };\n\n  const initial = email.trim()[0]?.toUpperCase() ?? \"Y\";\n  const motionFade = reduceMotion\n    ? { ...fade, initial: { opacity: 0 }, exit: { opacity: 0 } }\n    : fade;\n\n  return (\n    <section\n      aria-labelledby={`${id}-title`}\n      className={cn(\n        \"w-[min(440px,100%)] rounded-[20px] bg-background p-6 shadow-raised\",\n        className,\n      )}\n    >\n      <div className=\"flex h-6 items-center justify-between\">\n        <p className=\"text-[13px] text-muted-foreground\">{product} · Private beta</p>\n        <AnimatePresence initial={false}>\n          {joined && (\n            <motion.button\n              {...motionFade}\n              type=\"button\"\n              onClick={reset}\n              className=\"-mr-2 h-8 rounded-lg px-2 text-[13px] text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n            >\n              Not you?\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <h2\n        id={`${id}-title`}\n        className=\"relative mt-2 h-8 text-2xl font-semibold text-foreground\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {joined ? (\n            <motion.span key=\"spot\" {...motionFade} className=\"absolute inset-0\">\n              You&rsquo;re #<Roll value={position} />\n              <span className=\"sr-only\">{format(position)} in line</span>\n            </motion.span>\n          ) : (\n            <motion.span key=\"ask\" {...motionFade} className=\"absolute inset-0\">\n              Join the waitlist\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </h2>\n      <p className=\"mt-2 min-h-10 text-sm text-pretty text-muted-foreground\">\n        {joined\n          ? \"The next 200 people get in on Friday. You will hear from us the moment you do.\"\n          : `${product} lets in 200 new people every Friday. Save your spot in line.`}\n      </p>\n\n      {/* The line: front on the left, fading out because it keeps going. */}\n      <div className=\"mt-5 rounded-[16px] bg-muted p-3.5\">\n        <ol\n          aria-label={\n            joined\n              ? `Queue, you are number ${format(position)}`\n              : `Queue, ${format(waiting)} people waiting`\n          }\n          // Padded and pulled back by 8px so the \"You\" badge is not clipped.\n          className=\"-my-2 flex justify-end gap-2 overflow-hidden py-2 [mask-image:linear-gradient(to_right,transparent,black_56px)]\"\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {line.map((person) => {\n              const isMe = person.id === \"me\";\n              return (\n                <motion.li\n                  key={person.id}\n                  layout=\"position\"\n                  aria-label={isMe ? \"You\" : person.name}\n                  className={cn(\n                    \"relative flex w-9 shrink-0 justify-center\",\n                    isMe && \"z-10\",\n                  )}\n                  initial={\n                    reduceMotion\n                      ? { opacity: 0 }\n                      : { opacity: 0, y: -20, filter: \"blur(4px)\" }\n                  }\n                  animate={{\n                    opacity: 1,\n                    // Cutting the line: a small hop over the people you pass.\n                    y: isMe && bumped && !reduceMotion ? [0, -10, 0] : 0,\n                    filter: \"blur(0px)\",\n                  }}\n                  exit={{\n                    opacity: 0,\n                    x: reduceMotion ? 0 : -12,\n                    filter: \"blur(2px)\",\n                    transition: { duration: 0.2, ease: EASE_OUT },\n                  }}\n                  transition={{\n                    ...DROP,\n                    layout: SHUFFLE,\n                    y: isMe && bumped ? { duration: 0.45, ease: [0.77, 0, 0.175, 1] } : DROP,\n                  }}\n                >\n                  {isMe ? (\n                    <>\n                      <span className=\"flex size-9 items-center justify-center rounded-full bg-foreground text-sm font-semibold text-background\">\n                        {initial}\n                      </span>\n                      {/* A name badge pinned to the bottom of your circle,\n                          inside the strip's padding so nothing shifts. */}\n                      <span\n                        aria-hidden\n                        className=\"absolute -bottom-2 rounded-full bg-background px-1.5 text-[12px] leading-4 font-medium text-foreground shadow-[0_1px_2px_oklch(0_0_0/0.15)]\"\n                      >\n                        You\n                      </span>\n                    </>\n                  ) : (\n                    <Face person={person} />\n                  )}\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </ol>\n      </div>\n      <div className=\"mt-2 flex items-center justify-between text-[13px] text-muted-foreground\">\n        <span className=\"flex items-center gap-1\">\n          <svg\n            viewBox=\"0 0 16 16\"\n            aria-hidden\n            className=\"size-3.5\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <path d=\"M12.5 8h-9M7 4.5 3.5 8 7 11.5\" />\n          </svg>\n          Front of the line\n        </span>\n        <span className=\"relative tabular-nums\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span key={`${joined}-${position}`} {...motionFade} className=\"block\">\n              {joined\n                ? `${format(position - 1)} ahead of you`\n                : `${format(waiting)} waiting`}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </div>\n\n      {/* The form and the share row share one slot, so the page never jumps. */}\n      <div className=\"relative mt-5 h-11\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {joined ? (\n            <motion.div\n              key=\"share\"\n              {...motionFade}\n              className=\"absolute inset-0 flex items-center gap-2 rounded-[12px] bg-muted pr-1 pl-3.5\"\n            >\n              <span className=\"min-w-0 flex-1 truncate font-mono text-[13px] text-foreground\">\n                {link}\n              </span>\n              <button\n                ref={copyRef}\n                type=\"button\"\n                onClick={copy}\n                aria-describedby={`${id}-note`}\n                className=\"flex h-9 shrink-0 items-center gap-1.5 rounded-[9px] bg-foreground px-3 text-[13px] font-medium text-background outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n              >\n                <span className=\"relative size-3.5\">\n                  <AnimatePresence initial={false}>\n                    <motion.svg\n                      key={copied ? \"check\" : \"copy\"}\n                      viewBox=\"0 0 16 16\"\n                      aria-hidden\n                      className=\"absolute inset-0 size-3.5\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth={1.75}\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      initial={\n                        reduceMotion\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }\n                      }\n                      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                      exit={\n                        reduceMotion\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }\n                      }\n                      transition={{ type: \"spring\", duration: 0.3, bounce: 0 }}\n                    >\n                      {copied ? (\n                        <path d=\"m3.5 8.5 3 3 6-7\" />\n                      ) : (\n                        <>\n                          <rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1.5\" />\n                          <path d=\"M10.5 3.5v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h.5\" />\n                        </>\n                      )}\n                    </motion.svg>\n                  </AnimatePresence>\n                </span>\n                {copied ? \"Copied\" : \"Copy link\"}\n              </button>\n            </motion.div>\n          ) : (\n            <motion.form\n              key=\"form\"\n              {...motionFade}\n              noValidate\n              onSubmit={(e) => {\n                e.preventDefault();\n                join();\n              }}\n              className=\"absolute inset-0 flex gap-2\"\n            >\n              <label htmlFor={`${id}-email`} className=\"sr-only\">\n                Email\n              </label>\n              <input\n                ref={inputRef}\n                id={`${id}-email`}\n                type=\"email\"\n                inputMode=\"email\"\n                autoComplete=\"email\"\n                autoCapitalize=\"none\"\n                spellCheck={false}\n                placeholder=\"you@studio.com\"\n                value={email}\n                disabled={status === \"joining\"}\n                aria-invalid={error ? true : undefined}\n                aria-describedby={`${id}-note`}\n                onChange={(e) => setEmail(e.target.value)}\n                onBlur={() => {\n                  if (email.trim()) setTouched(true);\n                }}\n                className={cn(\n                  \"h-11 min-w-0 flex-1 rounded-[12px] bg-muted px-3.5 text-[15px] text-foreground outline-hidden transition-[box-shadow] duration-150 ease-out placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n                  error && \"shadow-[inset_0_0_0_1px_var(--color-destructive)]\",\n                )}\n              />\n              <button\n                type=\"submit\"\n                aria-busy={status === \"joining\" || undefined}\n                className=\"relative flex h-11 w-[84px] shrink-0 items-center justify-center rounded-[12px] bg-foreground text-sm font-medium text-background outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n              >\n                <span\n                  className={cn(\n                    \"transition-[opacity,filter] duration-150 ease-out\",\n                    status === \"joining\" ? \"opacity-0 blur-[2px]\" : \"opacity-100\",\n                  )}\n                >\n                  Join\n                </span>\n                <svg\n                  viewBox=\"0 0 16 16\"\n                  aria-hidden\n                  className={cn(\n                    \"absolute size-4 animate-spin transition-[opacity] duration-150 ease-out motion-reduce:animate-none\",\n                    status === \"joining\" ? \"opacity-100\" : \"opacity-0\",\n                  )}\n                  fill=\"none\"\n                >\n                  <circle cx=\"8\" cy=\"8\" r=\"6\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"2\" />\n                  <path d=\"M14 8a6 6 0 0 0-6-6\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n                </svg>\n                <span className=\"sr-only\">\n                  {status === \"joining\" ? \"Joining\" : \"\"}\n                </span>\n              </button>\n            </motion.form>\n          )}\n        </AnimatePresence>\n      </div>\n      <p\n        id={`${id}-note`}\n        aria-live=\"polite\"\n        className={cn(\n          // Two lines tall for the longest note on a phone; every note fits\n          // one line at the full 440px.\n          \"relative mt-2 h-10 text-[13px] leading-5 sm:h-5\",\n          error ? \"text-destructive\" : \"text-muted-foreground\",\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={error || (joined ? (bumped ? \"bumped\" : \"share\") : \"idle\")}\n            {...motionFade}\n            className=\"absolute inset-0 text-pretty\"\n          >\n            {error ||\n              (joined\n                ? bumped\n                  ? `You jumped ${skip} spots. Every friend who joins moves you up again.`\n                  : `Skip ahead by sharing: your link moves you up ${skip} spots.`\n                : \"One email when your invite is ready. Nothing else.\")}\n          </motion.span>\n        </AnimatePresence>\n      </p>\n    </section>\n  );\n}\n\n// Faces are \"Notionists\" by Zoish, CC0 1.0, stored in public/avatars. The\n// people further up the line are plain silhouettes: too far off to make\n// out, and never a face mixed in among letters.\nconst QUEUE: QueuePerson[] = [\n  { id: \"q0\", name: \"Priya Nair\" },\n  { id: \"q1\", name: \"Hugo Laurent\" },\n  { id: \"q2\", name: \"Noah Fischer\" },\n  { id: \"q3\", name: \"Lena Park\" },\n  { id: \"q4\", name: \"Omar Haddad\" },\n  { id: \"q5\", name: \"Amara Okafor\", avatar: \"/avatars/ava.svg\" },\n  { id: \"q6\", name: \"Jonas Weber\", avatar: \"/avatars/ben.svg\" },\n  { id: \"q7\", name: \"Mei Tanaka\", avatar: \"/avatars/cara.svg\" },\n  { id: \"q8\", name: \"Rafael Costa\", avatar: \"/avatars/dev.svg\" },\n  { id: \"q9\", name: \"Sara Lindqvist\", avatar: \"/avatars/fay.svg\" },\n];\n\nexport default function WaitlistJoinDemo() {\n  return <WaitlistJoin waiting={1283} queue={QUEUE} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/waitlist-join"}