{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expand-input",
  "title": "Expand Input",
  "description": "A pill button that morphs into an email field, then a spinner and a drawn check on submit.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/expand-input/expand-input.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\ntype State = \"idle\" | \"open\" | \"loading\" | \"success\"\n\nexport interface ExpandInputProps {\n  /** Label of the collapsed button. Default: \"Subscribe\" */\n  label?: string\n  /** Input placeholder. Default: \"you@example.com\" */\n  placeholder?: string\n  /** Message shown after a successful submit. Default: \"You're in!\" */\n  successLabel?: string\n  /** Called with the email on submit. The spinner waits for the promise; a rejection returns to the input. */\n  onSubmit?: (value: string) => Promise<unknown> | void\n  /** Collapse back to the button this many ms after success (0 = stay). Default: 2500 */\n  resetAfter?: number\n  /** Width of the expanded input, in px. Default: 300 */\n  width?: number\n  className?: string\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nconst ArrowRight = () => (\n  <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n    <path d=\"M3 8h10M9 4l4 4-4 4\" />\n  </svg>\n)\n\nconst Check = () => (\n  <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n    <motion.path\n      d=\"M3 8.5 6.5 12 13 4.5\"\n      initial={{ pathLength: 0 }}\n      animate={{ pathLength: 1 }}\n      transition={{ duration: 0.4, ease: \"easeOut\", delay: 0.15 }}\n    />\n  </svg>\n)\n\nexport function ExpandInput({\n  label = \"Subscribe\",\n  placeholder = \"you@example.com\",\n  successLabel = \"You're in!\",\n  onSubmit,\n  resetAfter = 2500,\n  width = 300,\n  className,\n}: ExpandInputProps) {\n  const reduced = useReducedMotion()\n  const [state, setState] = useState<State>(\"idle\")\n  const [email, setEmail] = useState(\"\")\n  const [invalid, setInvalid] = useState(false)\n  const [returnFocus, setReturnFocus] = useState(false)\n  const [scope, animate] = useAnimate<HTMLDivElement>()\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n  useEffect(() => () => clearTimeout(timer.current), [])\n\n  const expanded = state === \"open\" || state === \"loading\"\n  const spring = reduced ? { duration: 0 } : { type: \"spring\" as const, stiffness: 380, damping: 32 }\n  const fade = {\n    initial: { opacity: 0, filter: \"blur(4px)\", scale: reduced ? 1 : 0.96 },\n    animate: { opacity: 1, filter: \"blur(0px)\", scale: 1 },\n    exit: { opacity: 0, filter: \"blur(4px)\", scale: reduced ? 1 : 0.96 },\n    transition: reduced ? { duration: 0 } : { duration: 0.2 },\n  }\n\n  const collapse = (focusButton: boolean) => {\n    setState(\"idle\")\n    setInvalid(false)\n    setReturnFocus(focusButton)\n  }\n\n  const shake = () => {\n    if (reduced || !scope.current) return\n    animate(scope.current, { x: [0, -6, 6, -4, 4, 0] }, { duration: 0.4 })\n  }\n\n  const submit = async (e: React.FormEvent) => {\n    e.preventDefault()\n    if (state !== \"open\") return\n    const value = email.trim()\n    if (!EMAIL.test(value)) {\n      setInvalid(true)\n      shake()\n      return\n    }\n    setState(\"loading\")\n    try {\n      const minDelay = new Promise((r) => setTimeout(r, reduced ? 0 : 700))\n      await Promise.all([Promise.resolve(onSubmit?.(value)), minDelay])\n    } catch {\n      setState(\"open\")\n      shake()\n      return\n    }\n    setState(\"success\")\n    setEmail(\"\")\n    if (resetAfter > 0) timer.current = setTimeout(() => collapse(false), resetAfter)\n  }\n\n  return (\n    <motion.div\n      ref={scope}\n      initial={false}\n      animate={{ width: expanded ? width : \"auto\" }}\n      transition={spring}\n      className={cn(\n        \"relative inline-flex h-12 items-center overflow-hidden rounded-full border transition-colors duration-300\",\n        expanded ? \"bg-background text-foreground\" : \"border-transparent bg-foreground text-background\",\n        invalid && \"border-red-500/70\",\n        className\n      )}\n    >\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {state === \"success\" ? successLabel : state === \"loading\" ? \"Submitting\" : \"\"}\n      </span>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {state === \"idle\" && (\n          <motion.button\n            key=\"idle\"\n            {...fade}\n            type=\"button\"\n            autoFocus={returnFocus}\n            onClick={() => {\n              clearTimeout(timer.current)\n              setState(\"open\")\n            }}\n            className=\"h-full whitespace-nowrap rounded-full px-6 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n          >\n            {label}\n          </motion.button>\n        )}\n\n        {expanded && (\n          <motion.form\n            key=\"form\"\n            {...fade}\n            onSubmit={submit}\n            noValidate\n            onKeyDown={(e) => {\n              if (e.key === \"Escape\" && state === \"open\") collapse(true)\n            }}\n            onBlur={(e) => {\n              if (!email && state === \"open\" && !e.currentTarget.contains(e.relatedTarget)) collapse(false)\n            }}\n            className=\"flex h-full w-full items-center gap-2 py-1.5 pl-5 pr-1.5\"\n          >\n            <input\n              type=\"email\"\n              autoFocus\n              value={email}\n              disabled={state === \"loading\"}\n              onChange={(e) => {\n                setEmail(e.target.value)\n                setInvalid(false)\n              }}\n              placeholder={placeholder}\n              aria-label=\"Email address\"\n              aria-invalid={invalid || undefined}\n              className=\"h-full min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60\"\n            />\n            <button\n              type=\"submit\"\n              aria-label=\"Submit\"\n              disabled={state === \"loading\"}\n              className=\"grid aspect-square h-full shrink-0 place-items-center rounded-full bg-foreground text-background outline-none transition-transform active:scale-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n            >\n              {state === \"loading\" ? (\n                <motion.span\n                  className=\"size-4 rounded-full border-2 border-current border-t-transparent\"\n                  animate={{ rotate: 360 }}\n                  transition={{ duration: 0.8, repeat: Infinity, ease: \"linear\" }}\n                />\n              ) : (\n                <ArrowRight />\n              )}\n            </button>\n          </motion.form>\n        )}\n\n        {state === \"success\" && (\n          <motion.span\n            key=\"success\"\n            {...fade}\n            className=\"flex h-full items-center gap-2 whitespace-nowrap px-6 text-sm font-medium\"\n          >\n            <Check />\n            {successLabel}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </motion.div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/expand-input",
  "type": "registry:component"
}