{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-invite",
  "type": "registry:ui",
  "title": "Morph Invite",
  "description": "An avatar invite pill that expands into an email field with coordinated GSAP spring motion.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/morph-invite.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState, type FormEvent } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { calcGeneratorDuration, spring } from \"motion\";\nimport { Check, LoaderCircle, Mail, Plus } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface MorphInviteMember {\n  id: string;\n  name: string;\n  email?: string;\n  color?: string;\n}\n\nexport interface MorphInviteProps {\n  members?: readonly MorphInviteMember[];\n  onInvite: (\n    email: string,\n    signal: AbortSignal,\n  ) => void | MorphInviteMember | Promise<void | MorphInviteMember>;\n  stiffness?: number;\n  damping?: number;\n  mass?: number;\n  speed?: number;\n  expandedWidth?: number;\n  disabled?: boolean;\n  className?: string;\n}\n\nconst defaultMembers: readonly MorphInviteMember[] = [\n  { id: \"alex\", name: \"Alex\", color: \"#e9e4d7\" },\n  { id: \"maya\", name: \"Maya\", color: \"#dce5df\" },\n  { id: \"leo\", name: \"Leo\", color: \"#e3dff0\" },\n];\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nexport function MorphInvite({\n  members = defaultMembers,\n  onInvite,\n  stiffness = 420,\n  damping = 32,\n  mass = 0.8,\n  speed = 1,\n  expandedWidth = 360,\n  disabled = false,\n  className,\n}: MorphInviteProps) {\n  const uid = useId();\n  const [state, setState] = useState<\n    \"idle\" | \"editing\" | \"sending\" | \"sent\"\n  >(\"idle\");\n  const [email, setEmail] = useState(\"\");\n  const [invited, setInvited] = useState<MorphInviteMember[]>([]);\n  const [error, setError] = useState(\"\");\n  const [announcement, setAnnouncement] = useState(\"\");\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const pillRef = useRef<HTMLDivElement>(null);\n  const formRef = useRef<HTMLFormElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const spinnerRef = useRef<SVGSVGElement>(null);\n  const requestRef = useRef<AbortController | null>(null);\n  const focusRef = useRef<\"input\" | \"trigger\" | null>(null);\n  const layoutRef = useRef<((immediate?: boolean) => void) | null>(null);\n  const timelineRef = useRef<gsap.core.Timeline | null>(null);\n  const seenRef = useRef(new Set<string>());\n  const expanded = state !== \"idle\";\n  const busy = state === \"sending\" || state === \"sent\";\n  const validEmail = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email.trim());\n  const roster = useMemo(\n    () => [\n      ...invited,\n      ...members.filter(\n        (member) => !invited.some((item) => item.id === member.id),\n      ),\n    ],\n    [invited, members],\n  );\n  const visibleMembers = roster.slice(0, 4);\n  const avatarWidth = visibleMembers.length\n    ? 32 + (visibleMembers.length - 1) * 21\n    : 0;\n  const compactWidth = visibleMembers.length\n    ? 32 + (visibleMembers.length - 1) * 5\n    : 0;\n  const rosterKey = visibleMembers.map((member) => member.id).join(\"|\");\n\n  useGSAP(\n    () => {\n      const wrapper = wrapperRef.current;\n      const pill = pillRef.current;\n      const form = formRef.current;\n      const trigger = triggerRef.current;\n      if (!wrapper || !pill || !form || !trigger) return;\n\n      const response = spring({\n        keyframes: [0, 1],\n        stiffness: bounded(stiffness, 420, 80, 800),\n        damping: bounded(damping, 32, 12, 80),\n        mass: bounded(mass, 0.8, 0.25, 3),\n        restSpeed: 0.001,\n        restDelta: 0.001,\n      });\n      const settleTime = calcGeneratorDuration(response) / 1000;\n      const springEase = (progress: number) =>\n        progress === 1 ? 1 : response.next(progress * settleTime * 1000).value;\n      const media = gsap.matchMedia();\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          standard: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          const sync = context.add(\"sync\", (immediate = false) => {\n            const avatars = Array.from(\n              pill.querySelectorAll<HTMLElement>(\"[data-invite-avatar]\"),\n            );\n            const isExpanded = pill.dataset.state !== \"idle\";\n            const stackWidth = avatars.length ? 32 + (avatars.length - 1) * 21 : 0;\n            const targetWidth = isExpanded\n              ? bounded(expandedWidth, 360, 280, 560)\n              : stackWidth + (stackWidth ? 16 : 8) + 98;\n            const reduced = Boolean(context.conditions?.reduced);\n            const duration = immediate || reduced\n              ? 0\n              : settleTime / bounded(speed, 1, 0.25, 3);\n            const fadeDuration = immediate || reduced\n              ? 0\n              : Math.min(0.18, duration);\n\n            timelineRef.current?.kill();\n            const timeline = gsap.timeline({\n              defaults: { duration, ease: springEase, overwrite: \"auto\" },\n            });\n            timelineRef.current = timeline;\n            timeline.to(\n              pill,\n              { width: Math.min(targetWidth, wrapper.clientWidth) },\n              0,\n            );\n            timeline.to(trigger, {\n              autoAlpha: isExpanded ? 0 : 1,\n              x: isExpanded ? -6 : 0,\n              duration: fadeDuration,\n              ease: \"power2.out\",\n            }, 0);\n            timeline.to(form, {\n              autoAlpha: isExpanded ? 1 : 0,\n              x: isExpanded ? 0 : 8,\n              duration: fadeDuration,\n              ease: \"power2.out\",\n            }, isExpanded && duration ? 0.06 : 0);\n            avatars.forEach((avatar, index) => {\n              const id = avatar.dataset.inviteAvatar!;\n              if (!immediate && !reduced && !seenRef.current.has(id)) {\n                gsap.set(avatar, {\n                  x: 54, y: -22, scale: 0.45, opacity: 0,\n                });\n              }\n              timeline.to(avatar, {\n                x: index * (isExpanded ? 5 : 21),\n                y: 0,\n                scale: isExpanded ? 0.9 : 1,\n                opacity: 1,\n              }, 0);\n              seenRef.current.add(id);\n            });\n            timeline.call(() => {\n              if (focusRef.current === \"input\" && isExpanded) {\n                inputRef.current?.focus({ preventScroll: true });\n                focusRef.current = null;\n              } else if (focusRef.current === \"trigger\" && !isExpanded) {\n                trigger.focus({ preventScroll: true });\n                focusRef.current = null;\n              }\n            }, [], fadeDuration + (isExpanded && duration ? 0.06 : 0));\n          });\n\n          layoutRef.current = (immediate) => sync(immediate);\n          sync(true);\n          const observer = new ResizeObserver(() => sync(true));\n          observer.observe(wrapper);\n          return () => {\n            observer.disconnect();\n            timelineRef.current?.kill();\n            layoutRef.current = null;\n          };\n        },\n        wrapper,\n      );\n      return () => media.revert();\n    },\n    {\n      scope: wrapperRef,\n      dependencies: [stiffness, damping, mass, speed, expandedWidth],\n      revertOnUpdate: true,\n    },\n  );\n\n  useGSAP(() => layoutRef.current?.(), {\n    scope: wrapperRef,\n    dependencies: [expanded, rosterKey],\n  });\n\n  useGSAP(\n    () => {\n      if (state === \"sent\") {\n        gsap.delayedCall(Math.max(0.85, timelineRef.current?.duration() ?? 0), () => {\n          const restoreFocus = pillRef.current?.contains(document.activeElement);\n          if (restoreFocus) focusRef.current = \"trigger\";\n          setState(\"idle\");\n          setEmail(\"\");\n        });\n      }\n      if (state !== \"sending\" || !spinnerRef.current) return;\n      const media = gsap.matchMedia();\n      media.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        gsap.to(spinnerRef.current, {\n          rotation: 360,\n          duration: 0.8,\n          repeat: -1,\n          ease: \"none\",\n          transformOrigin: \"center\",\n        });\n      });\n      return () => media.revert();\n    },\n    { scope: wrapperRef, dependencies: [state], revertOnUpdate: true },\n  );\n\n  useEffect(() => {\n    if (state !== \"editing\") return;\n    inputRef.current?.focus({ preventScroll: true });\n    const onPointerDown = (event: PointerEvent) => {\n      if (!pillRef.current?.contains(event.target as Node) && !requestRef.current) {\n        focusRef.current = null;\n        setState(\"idle\");\n        setError(\"\");\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [state]);\n\n  useEffect(() => () => requestRef.current?.abort(), []);\n\n  const submit = async (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    if (disabled || busy || requestRef.current) return;\n    const address = email.trim();\n    if (!validEmail) {\n      setError(address ? \"Enter a valid email address.\" : \"Enter an email address.\");\n      inputRef.current?.focus();\n      return;\n    }\n    if (roster.some((member) => member.email?.toLowerCase() === address.toLowerCase())) {\n      setError(\"This person has already been invited.\");\n      inputRef.current?.focus();\n      return;\n    }\n    const controller = new AbortController();\n    requestRef.current = controller;\n    setError(\"\");\n    setState(\"sending\");\n    setAnnouncement(`Sending an invitation to ${address}.`);\n    try {\n      const member = await onInvite(address, controller.signal);\n      if (controller.signal.aborted) return;\n      const newMember = member ?? {\n        id: address.toLowerCase(),\n        email: address,\n        name: address.split(\"@\")[0],\n        color: \"#4f46e5\",\n      };\n      setInvited((current) => [\n        newMember,\n        ...current.filter((item) => item.id !== newMember.id),\n      ]);\n      setState(\"sent\");\n      setAnnouncement(`Invitation sent to ${address}.`);\n    } catch (reason) {\n      if (controller.signal.aborted) return;\n      setState(\"editing\");\n      setError(\n        reason instanceof Error && reason.message\n          ? reason.message\n          : \"Couldn’t send the invitation. Try again.\",\n      );\n      setAnnouncement(\"\");\n    } finally {\n      if (requestRef.current === controller) requestRef.current = null;\n    }\n  };\n\n  return (\n    <div ref={wrapperRef} className={cn(\"relative flex w-full max-w-full flex-col items-center\", className)}>\n      <div\n        ref={pillRef}\n        data-state={state}\n        className=\"relative isolate h-12 w-[188px] max-w-full rounded-full border border-black/5 bg-white text-zinc-900 shadow-[0_2px_3px_-1px_rgba(0,0,0,0.06),0_8px_24px_-12px_rgba(0,0,0,0.18),inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-white/10 dark:bg-[#242426] dark:text-zinc-100 dark:shadow-[0_8px_28px_-12px_rgba(0,0,0,0.6)]\"\n        onKeyDown={(event) => {\n          if (event.key === \"Escape\" && state === \"editing\" && !requestRef.current) {\n            event.preventDefault();\n            event.stopPropagation();\n            setState(\"idle\");\n            setError(\"\");\n            focusRef.current = \"trigger\";\n          }\n        }}\n      >\n        <div role=\"group\" aria-label={`${roster.length} team members`} className=\"pointer-events-none absolute top-[7px] left-[7px] h-8\" style={{ width: avatarWidth }}>\n          {visibleMembers.map((member, index) => (\n            <span\n              key={member.id}\n              data-invite-avatar={member.id}\n              aria-label={member.name}\n              title={member.name}\n              className=\"absolute top-0 left-0 flex size-8 items-center justify-center rounded-full border-2 border-white text-[11px] font-medium dark:border-[#242426]\"\n              style={{\n                zIndex: visibleMembers.length - index,\n                backgroundColor: member.color ?? \"#e3dff0\",\n                color: member.color === \"#4f46e5\" ? \"#ffffff\" : \"#3f3f46\",\n              }}\n            >\n              {member.name.trim().charAt(0).toUpperCase() || \"?\"}\n            </span>\n          ))}\n        </div>\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          disabled={disabled}\n          tabIndex={expanded ? -1 : 0}\n          aria-hidden={expanded || undefined}\n          aria-expanded={expanded}\n          aria-controls={`${uid}-form`}\n          onClick={() => {\n            focusRef.current = \"input\";\n            setError(\"\");\n            setState(\"editing\");\n          }}\n          className=\"absolute top-[5px] right-[5px] flex h-9 w-[92px] cursor-pointer items-center justify-center gap-2 rounded-full bg-[#efeee8] text-xs font-medium hover:bg-[#e8e7df] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white/8 dark:hover:bg-white/12\"\n        >\n          <Plus className=\"size-3.5\" strokeWidth={2} aria-hidden=\"true\" />\n          Invite\n        </button>\n        <form\n          ref={formRef}\n          id={`${uid}-form`}\n          aria-label=\"Invite a team member\"\n          aria-hidden={!expanded || undefined}\n          aria-busy={state === \"sending\"}\n          inert={!expanded}\n          noValidate\n          onSubmit={submit}\n          className=\"invisible absolute top-[5px] right-[5px] flex h-9 min-w-0 items-center gap-2 rounded-full bg-[#efeee8] pl-3 pr-1 opacity-0 dark:bg-white/8\"\n          style={{ left: compactWidth + (compactWidth ? 15 : 5) }}\n        >\n          <Mail className=\"size-3.5 shrink-0 text-zinc-600 dark:text-zinc-400\" strokeWidth={1.8} aria-hidden=\"true\" />\n          <input\n            ref={inputRef}\n            type=\"email\"\n            name=\"email\"\n            required\n            autoComplete=\"email\"\n            autoCapitalize=\"none\"\n            spellCheck={false}\n            aria-label=\"Email address\"\n            aria-invalid={Boolean(error)}\n            aria-describedby={error ? `${uid}-error` : undefined}\n            placeholder=\"Invite by email\"\n            value={email}\n            disabled={disabled}\n            readOnly={busy}\n            onChange={(event) => {\n              setEmail(event.target.value);\n              setError(\"\");\n            }}\n            className={cn(\n              \"h-full w-full min-w-0 flex-1 bg-transparent text-[16px] tracking-tight outline-none placeholder:text-zinc-500 sm:text-xs dark:placeholder:text-zinc-400\",\n              state === \"sent\" && \"text-zinc-500 dark:text-zinc-400\",\n            )}\n          />\n          <button\n            type=\"submit\"\n            disabled={disabled || busy}\n            aria-label={\n              state === \"sending\" ? \"Sending invitation\"\n                : state === \"sent\" ? \"Invitation sent\" : \"Send invitation\"\n            }\n            className={cn(\n              \"relative flex h-7 min-w-[56px] shrink-0 cursor-pointer items-center before:absolute before:inset-x-0 before:-inset-y-1.5 justify-center gap-1 rounded-full px-2.5 text-[11px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:cursor-default\",\n              state === \"sent\"\n                ? \"bg-indigo-600 text-white\"\n                : validEmail\n                  ? \"bg-zinc-950 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white\"\n                  : \"bg-[#deddd5] text-zinc-600 dark:bg-white/10 dark:text-zinc-400\",\n            )}\n          >\n            {state === \"sending\" ? (\n              <LoaderCircle ref={spinnerRef} className=\"size-3.5\" aria-hidden=\"true\" />\n            ) : state === \"sent\" ? (\n              <><Check className=\"size-3\" aria-hidden=\"true\" />Sent</>\n            ) : \"Send\"}\n          </button>\n        </form>\n      </div>\n      <p\n        id={`${uid}-error`}\n        role=\"alert\"\n        className=\"absolute top-full mt-3 max-w-full text-center text-xs text-rose-600 dark:text-rose-400\"\n      >\n        {error}\n      </p>\n      <span role=\"status\" className=\"sr-only\">{announcement}</span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/morph-invite.tsx"
    }
  ]
}
