{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "type": "registry:ui",
  "slug": "morph-invite",
  "name": "Morph Invite",
  "title": "Morph Invite",
  "description": "An avatar invite pill that expands into an email field with coordinated GSAP spring motion.",
  "summary": "A compact team invitation control inspired by the reference video. The avatar stack compresses as the email field opens, submission shows pending and success states, and a new avatar settles into the stack before the pill closes. Includes async callbacks, retryable errors, duplicate checks, keyboard support, and reduced motion.",
  "category": "inputs",
  "tags": [
    "invite",
    "email",
    "avatar",
    "pill",
    "morph",
    "spring",
    "gsap",
    "team"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-05",
  "updatedDate": "2026-10-05",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Coordinated pill width expansion and avatar stack compression (tighter overlap and 0.9 scale)",
    "Adjustable stiffness, damping, mass, playback speed, and expanded width",
    "Async invite callback with pending, success, and retryable failure states",
    "New avatar entrance following a successful callback",
    "Email validation, duplicate prevention, and abort signal on unmount",
    "Responsive width, keyboard navigation, and reduced-motion support"
  ],
  "anatomy": [
    "<MorphInvite> responsive wrapper and morphing pill",
    "Overlapping initials avatars with animated spacing",
    "Invite trigger and inline email form",
    "Pending spinner and success confirmation",
    "Accessible error message and live status"
  ],
  "physics": {
    "engine": "GSAP timeline with a spring easing function",
    "description": "Uses the existing Motion spring generator as easing for GSAP. Pill geometry, avatar spacing, and face crossfades share one interruptible timeline.",
    "parameters": [
      {
        "label": "Stiffness",
        "value": "420"
      },
      {
        "label": "Damping",
        "value": "32"
      },
      {
        "label": "Mass",
        "value": "0.8"
      },
      {
        "label": "Playback speed",
        "value": "1"
      }
    ]
  },
  "accessibility": {
    "role": "form",
    "aria": "Labeled email input, aria-expanded trigger, inert hidden form, pending state, live status, and linked error message.",
    "reducedMotion": "Skips geometry, avatar, and spinner motion when prefers-reduced-motion is active.",
    "keyboard": [
      {
        "key": "Enter / Space",
        "description": "Open the invite field from the trigger."
      },
      {
        "key": "Enter",
        "description": "Submit a valid email address."
      },
      {
        "key": "Escape",
        "description": "Close the field and return focus to the invite trigger."
      },
      {
        "key": "Tab",
        "description": "Navigate between the email field and send button."
      }
    ]
  },
  "guidelines": {
    "recommended": [
      "Team toolbars and collaborative project headers",
      "Workspace invitations and document sharing"
    ],
    "bestPractices": [
      "Connect onInvite to your invitation service and resolve only after successful submission.",
      "Use the provided AbortSignal to cancel work when the component unmounts.",
      "Return a member from onInvite to customize the new avatar.",
      "The showcase simulates submission locally; it sends no email."
    ]
  },
  "props": [
    {
      "name": "members",
      "type": "readonly MorphInviteMember[]",
      "defaultValue": "Alex, Maya, Leo",
      "description": "Team members with unique id, name, optional email, and optional avatar background color."
    },
    {
      "name": "onInvite",
      "type": "(email: string, signal: AbortSignal) => void | MorphInviteMember | Promise<void | MorphInviteMember>",
      "required": true,
      "description": "Invite handler. Resolve on success, optionally return the new member, or throw an error to allow retry."
    },
    {
      "name": "stiffness",
      "type": "number",
      "defaultValue": "420",
      "description": "Spring stiffness, clamped to 80\u2013800."
    },
    {
      "name": "damping",
      "type": "number",
      "defaultValue": "32",
      "description": "Spring damping, clamped to 12\u201380."
    },
    {
      "name": "mass",
      "type": "number",
      "defaultValue": "0.8",
      "description": "Spring mass, clamped to 0.25\u20133."
    },
    {
      "name": "speed",
      "type": "number",
      "defaultValue": "1",
      "description": "Motion playback multiplier, clamped to 0.25\u20133."
    },
    {
      "name": "expandedWidth",
      "type": "number",
      "defaultValue": "360",
      "description": "Expanded pill width in pixels, constrained to its available container space."
    },
    {
      "name": "disabled",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Disables opening and submission."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Additional classes for the responsive wrapper."
    }
  ],
  "files": [
    {
      "name": "morph-invite.tsx",
      "path": "registry/ui/morph-invite.tsx",
      "code": "\"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",
      "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"
    }
  ],
  "usage": "\"use client\";\n\nimport { MorphInvite } from \"@/components/ui/morph-invite\";\n\nexport function MorphInviteDemo() {\n  return (\n    <MorphInvite\n      onInvite={async (email, signal) => {\n        await fetch(\"/api/invites\", {\n          method: \"POST\",\n          body: JSON.stringify({ email }),\n          signal,\n        });\n      }}\n    />\n  );\n}"
}
