{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morph-label","type":"registry:ui","title":"Morph Label","description":"A button that confirms in place: its label and icon morph through pending, done or error (Add to cart → Added → back) while the width springs to fit, plus a SwapSlot for any value that changes.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":["@geturui/button"],"files":[{"path":"registry/geturui/ui/morph-label.tsx","type":"registry:ui","target":"components/ui/morph-label.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonClasses, type ButtonSize, type ButtonVariant } from \"@/components/ui/button\";\n\nexport interface SwapSlotProps {\n  /** When this changes, the old child blurs out and the new one slides in. */\n  value: string | number | boolean;\n  children: React.ReactNode;\n  /** Direction the new content travels in from. */\n  direction?: \"up\" | \"down\" | \"left\" | \"right\";\n  /** Spring the slot's width to fit the new content. */\n  animateWidth?: boolean;\n  className?: string;\n}\n\nconst offsets = {\n  up: { x: 0, y: \"70%\" },\n  down: { x: 0, y: \"-70%\" },\n  left: { x: 14, y: 0 },\n  right: { x: -14, y: 0 },\n} as const;\n\n/** Animates any changing child: blur + slide crossfade keyed on `value`, with a spring on width. */\nexport function SwapSlot({ value, children, direction = \"up\", animateWidth = true, className }: SwapSlotProps) {\n  const reduce = useReducedMotion();\n  const [width, setWidth] = React.useState<number | \"auto\">(\"auto\");\n  const o = offsets[direction];\n\n  const measure = React.useCallback((el: HTMLSpanElement | null) => {\n    if (!el || !animateWidth) return;\n    const ro = new ResizeObserver(() => setWidth(el.offsetWidth));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [animateWidth]);\n\n  return (\n    <motion.span\n      className={cn(\"relative inline-flex items-center justify-center\", className)}\n      initial={false}\n      animate={{ width: animateWidth ? width : \"auto\" }}\n      transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 34 }}\n    >\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={String(value)}\n          ref={measure}\n          className=\"inline-flex shrink-0 items-center gap-[inherit] whitespace-nowrap\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, x: o.x, y: o.y, filter: \"blur(8px)\" }}\n          animate={{ opacity: 1, x: 0, y: 0, filter: \"blur(0px)\" }}\n          exit={\n            reduce\n              ? { opacity: 0 }\n              : {\n                  opacity: 0,\n                  x: typeof o.x === \"number\" ? -o.x : 0,\n                  y: typeof o.y === \"string\" ? (o.y.startsWith(\"-\") ? \"70%\" : \"-70%\") : 0,\n                  filter: \"blur(8px)\",\n                }\n          }\n          transition={{ type: \"spring\", stiffness: 400, damping: 32 }}\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nexport interface MorphLabelState {\n  label: React.ReactNode;\n  icon?: React.ReactNode;\n}\n\nexport interface MorphLabelProps {\n  /** Resting state, e.g. “Add to cart”. */\n  idle: MorphLabelState;\n  /** Shown after the action resolves, e.g. “Added”. */\n  done: MorphLabelState;\n  /** Shown while the action's promise is pending. Defaults to a spinner with the idle label. */\n  pending?: MorphLabelState;\n  /** Shown briefly when the action throws. */\n  error?: MorphLabelState;\n  /** Runs on click from the idle state. May return a promise. */\n  onAction?: () => void | Promise<unknown>;\n  /** Runs on click from the done state when `resetAfter` is 0 (toggle behaviour). */\n  onUndo?: () => void | Promise<unknown>;\n  /** ms before returning to idle; 0 keeps the done state until clicked again. */\n  resetAfter?: number;\n  variant?: ButtonVariant;\n  /** Variant used while in the done state. */\n  doneVariant?: ButtonVariant;\n  size?: ButtonSize;\n  disabled?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\ntype Phase = \"idle\" | \"pending\" | \"done\" | \"error\";\n\n/** CTA whose label and icon swap with a blur + slide crossfade (Add to cart → Added → back) while its width springs to fit. */\nexport function MorphLabel({\n  idle,\n  done,\n  pending,\n  error,\n  onAction,\n  onUndo,\n  resetAfter = 1800,\n  variant = \"primary\",\n  doneVariant = \"secondary\",\n  size = \"md\",\n  disabled,\n  className,\n  ...rest\n}: MorphLabelProps) {\n  const reduce = useReducedMotion();\n  const [phase, setPhase] = React.useState<Phase>(\"idle\");\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  React.useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  function later(next: Phase, ms: number) {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = setTimeout(() => setPhase(next), ms);\n  }\n\n  /** Runs `fn`; only shows the pending state when it actually returns a promise. */\n  async function settle(fn: (() => void | Promise<unknown>) | undefined) {\n    const result = fn?.();\n    if (result instanceof Promise) {\n      setPhase(\"pending\");\n      await result;\n    }\n  }\n\n  async function run() {\n    if (phase === \"pending\") return;\n    if (timer.current) clearTimeout(timer.current);\n    if (phase === \"done\") {\n      if (resetAfter > 0) return;\n      try {\n        await settle(onUndo);\n        setPhase(\"idle\");\n      } catch {\n        setPhase(\"done\");\n      }\n      return;\n    }\n    try {\n      await settle(onAction);\n      setPhase(\"done\");\n      if (resetAfter > 0) later(\"idle\", resetAfter);\n    } catch {\n      setPhase(\"error\");\n      later(\"idle\", 1400);\n    }\n  }\n\n  const pendingState: MorphLabelState = pending ?? {\n    label: idle.label,\n    icon: <Loader2 className=\"size-4 animate-spin\" aria-hidden />,\n  };\n  const state = phase === \"idle\" ? idle : phase === \"pending\" ? pendingState : phase === \"done\" ? done : (error ?? idle);\n  const v = phase === \"done\" ? doneVariant : phase === \"error\" ? \"danger\" : variant;\n\n  return (\n    <motion.button\n      type=\"button\"\n      onClick={run}\n      disabled={disabled}\n      aria-busy={phase === \"pending\" || undefined}\n      aria-pressed={resetAfter === 0 ? phase === \"done\" : undefined}\n      aria-label={rest[\"aria-label\"]}\n      whileTap={reduce ? undefined : { scale: 0.96 }}\n      animate={phase === \"error\" && !reduce ? { x: [0, -6, 6, -3, 3, 0] } : { x: 0 }}\n      transition={{ x: { duration: 0.36 }, scale: { type: \"spring\", stiffness: 520, damping: 26 } }}\n      className={buttonClasses({ variant: v, size, className: cn(\"overflow-hidden\", className) })}\n    >\n      <SwapSlot value={phase === \"pending\" && !pending ? \"pending\" : phase} className=\"gap-[inherit]\">\n        {state.icon}\n        <span>{state.label}</span>\n      </SwapSlot>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {phase === \"done\" && typeof done.label === \"string\" ? done.label : phase === \"error\" && error && typeof error.label === \"string\" ? error.label : \"\"}\n      </span>\n    </motion.button>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","button","cta","state","feedback","crossfade","add to cart","toggle","text swap"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/morph-label"}}