{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"waitlist-field","type":"registry:component","title":"Waitlist field","description":"An email field that becomes its own answer: the button stretches across it, then settles as \"You’re on the list · #1,248\".","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/waitlist-field.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * WAITLIST FIELD: an email field that becomes its own answer\n *\n *   idle     email and \"Join the waitlist\" in one pill\n *   invalid  the pill gives a small shake, its edge tints and\n *            a hint fades in underneath\n *   joining  the button stretches across the whole field and\n *            a light sweeps through it while the request runs\n *   joined   \"You're on the list · #1,248\": a check draws\n *            itself and the place number rolls up into view\n *   failed   the button folds back and asks you to try again\n *\n * Give it an onSubmit that returns a promise (resolve with\n * { position } to show the place in line).\n * ───────────────────────────────────────────────────────── */\n\nexport interface WaitlistResult {\n  position?: number;\n}\n\nexport interface WaitlistFieldProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, \"onSubmit\"> {\n  onSubmit: (email: string) => Promise<WaitlistResult | void> | WaitlistResult | void;\n  /** Show the joined state straight away, e.g. for someone already on the list. */\n  joined?: WaitlistResult | null;\n  placeholder?: string;\n  labels?: Partial<{ idle: string; pending: string; success: string; error: string; invalid: string }>;\n}\n\ntype Status = \"idle\" | \"pending\" | \"success\" | \"error\";\n\nconst LABELS = {\n  idle: \"Join the waitlist\",\n  pending: \"Joining\",\n  success: \"You’re on the list\",\n  error: \"Try again\",\n  invalid: \"Enter a full email address\",\n};\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/;\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst shake = (el: HTMLElement | null) =>\n  el?.animate(\n    [{ transform: \"none\" }, { transform: \"translateX(-5px)\" }, { transform: \"translateX(5px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n    { duration: 380, easing: \"ease-out\" }\n  );\n\nexport function WaitlistField({ onSubmit, joined, placeholder = \"you@company.com\", labels, className = \"\", ...props }: WaitlistFieldProps) {\n  const reduced = useReducedMotion();\n  const text = { ...LABELS, ...labels };\n  const id = React.useId();\n  const [status, setStatus] = React.useState<Status>(joined ? \"success\" : \"idle\");\n  const [position, setPosition] = React.useState<number | undefined>(joined?.position);\n  const [email, setEmail] = React.useState(\"\");\n  const [invalid, setInvalid] = React.useState(false);\n  const [restWidth, setRestWidth] = React.useState(0);\n  const pillRef = React.useRef<HTMLDivElement>(null);\n  const measureRef = React.useRef<HTMLSpanElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  // How wide the button is at rest: measured from its label, so the stretch starts exactly from there.\n  const restLabel = status === \"error\" ? text.error : text.idle;\n  React.useLayoutEffect(() => {\n    const el = measureRef.current;\n    if (!el) return;\n    const measure = () => setRestWidth((w) => (w === el.offsetWidth ? w : el.offsetWidth));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [restLabel]);\n\n  const submit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (status === \"pending\" || status === \"success\") return;\n    const value = email.trim();\n    if (!EMAIL.test(value)) {\n      setInvalid(true);\n      if (!reduced) shake(pillRef.current);\n      inputRef.current?.focus();\n      return;\n    }\n    setInvalid(false);\n    setStatus(\"pending\");\n    try {\n      const result = await onSubmit(value);\n      setPosition(result ? result.position : undefined);\n      setStatus(\"success\");\n    } catch {\n      setStatus(\"error\");\n      if (!reduced) shake(pillRef.current);\n    }\n  };\n\n  const covering = status === \"pending\" || status === \"success\";\n  const transition = reduced ? \"none\" : `width 560ms ${EASE}, background-color 300ms ${EASE}`;\n\n  return (\n    <form noValidate onSubmit={submit} className={`w-full ${className}`} {...props}>\n      <div\n        ref={pillRef}\n        className={`relative flex h-12 items-center rounded-full bg-background p-1 transition-[box-shadow] duration-300 ${\n          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)]\"\n        }`}\n      >\n        <label htmlFor={`${id}-email`} className=\"sr-only\">\n          Email address\n        </label>\n        <input\n          ref={inputRef}\n          id={`${id}-email`}\n          type=\"email\"\n          inputMode=\"email\"\n          autoComplete=\"email\"\n          value={email}\n          disabled={covering}\n          aria-invalid={invalid || undefined}\n          aria-describedby={invalid ? `${id}-hint` : undefined}\n          onChange={(e) => {\n            setEmail(e.target.value);\n            if (invalid) setInvalid(false);\n          }}\n          placeholder={placeholder}\n          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\"\n          style={{ paddingRight: restWidth + 12, transition: reduced ? \"none\" : \"opacity 200ms ease-out\" }}\n        />\n\n        {/* The one surface: rests as a button, stretches over the field, settles as the answer. */}\n        <button\n          type=\"submit\"\n          aria-disabled={covering || undefined}\n          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]\"\n          style={{ width: covering ? \"calc(100% - 8px)\" : restWidth || undefined, transition }}\n        >\n          {status === \"pending\" && (\n            <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-full\">\n              <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\" />\n            </span>\n          )}\n          <span className=\"relative flex items-center gap-2 whitespace-nowrap\">\n            <span\n              aria-hidden=\"true\"\n              className=\"flex h-4 items-center justify-center overflow-hidden\"\n              style={{ width: status === \"success\" ? 16 : 0, transition: reduced ? \"none\" : `width 380ms ${EASE}` }}\n            >\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 shrink-0\">\n                <path\n                  d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  pathLength={1}\n                  strokeDasharray={1}\n                  style={{ strokeDashoffset: status === \"success\" ? 0 : 1, transition: status === \"success\" && !reduced ? `stroke-dashoffset 460ms ${EASE} 260ms` : \"none\" }}\n                />\n              </svg>\n            </span>\n            <TextMorph>{status === \"pending\" ? text.pending : status === \"success\" ? text.success : restLabel}</TextMorph>\n            {status === \"success\" && position !== undefined && (\n              <span className=\"flex items-center gap-2 animate-[ui-fade-in_300ms_ease-out_200ms_both] motion-reduce:animate-none\">\n                <span aria-hidden=\"true\" className=\"opacity-50\">\n                  ·\n                </span>\n                <span className=\"tabular-nums\">\n                  #<NumberRoll value={position} from={Math.max(0, position - 36)} duration={1100} />\n                </span>\n              </span>\n            )}\n          </span>\n        </button>\n\n        {/* Measures the resting button, so its width can animate from an exact number. */}\n        <span ref={measureRef} aria-hidden=\"true\" className=\"invisible absolute whitespace-nowrap px-4 text-[13px] font-medium\">\n          {restLabel}\n        </span>\n      </div>\n\n      <p\n        id={`${id}-hint`}\n        className=\"mt-2 h-4 pl-4 text-[12px] text-red-600 transition-[opacity,transform] duration-300 dark:text-red-400\"\n        style={{ opacity: invalid ? 1 : 0, transform: invalid ? \"none\" : \"translateY(-3px)\" }}\n      >\n        {invalid ? text.invalid : \"\"}\n      </p>\n      <span role=\"status\" className=\"sr-only\">\n        {status === \"success\" ? `${text.success}${position !== undefined ? `, number ${position}` : \"\"}` : status === \"error\" ? \"That didn’t go through. Try again.\" : \"\"}\n      </span>\n    </form>\n  );\n}\n"}],"css":{"@keyframes ui-scan":{"from":{"transform":"translateX(-100%)"},"to":{"transform":"translateX(250%)"}},"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/waitlist-field"}