{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"icon-morph","type":"registry:component","title":"Icon morph","description":"Kinetic SVG icon transition primitive and button with scale, rotate, and 3D horizon flip animations.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/icon-morph.tsx","type":"registry:component","content":"// Hairline UI · icon-morph · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport {\n  INTERACTION_FOCUS,\n  useInteractionReducedMotion,\n} from \"./ui-interaction-motion\";\n\nexport type IconMorphMode = \"rotate\" | \"scale\" | \"flip\";\n\nexport interface IconMorphProps extends React.HTMLAttributes<HTMLSpanElement> {\n  /** The first icon (rendered when active is false). */\n  from: React.ReactNode;\n  /** The second icon (rendered when active is true). */\n  to: React.ReactNode;\n  /** Controls which icon is active. */\n  active?: boolean;\n  /** Morph animation style. Defaults to scale. */\n  mode?: IconMorphMode;\n  /** Icon bounding box size in pixels. Defaults to 16. */\n  size?: number;\n}\n\nexport interface IconMorphButtonProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> {\n  from: React.ReactNode;\n  to: React.ReactNode;\n  active?: boolean;\n  onActiveChange?: (next: boolean) => void;\n  mode?: IconMorphMode;\n  size?: \"sm\" | \"md\" | \"lg\";\n  variant?: \"outline\" | \"ghost\" | \"solid\";\n  label: string;\n}\n\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nconst EASE_SETTLE = \"cubic-bezier(0.22, 1.18, 0.36, 1)\";\n\n/**\n * Standalone kinetic SVG icon morph primitive.\n */\nexport function IconMorph({\n  from,\n  to,\n  active = false,\n  mode = \"scale\",\n  size = 16,\n  className = \"\",\n  style,\n  ...props\n}: IconMorphProps) {\n  const reduced = useInteractionReducedMotion();\n\n  // Mode-specific transforms\n  const getTransforms = () => {\n    if (reduced) {\n      return {\n        from: {\n          opacity: active ? 0 : 1,\n          transform: \"none\",\n        },\n        to: {\n          opacity: active ? 1 : 0,\n          transform: \"none\",\n        },\n      };\n    }\n\n    if (mode === \"rotate\") {\n      return {\n        from: {\n          opacity: active ? 0 : 1,\n          transform: active ? \"rotate(90deg) scale(0.65)\" : \"rotate(0deg) scale(1)\",\n          transition: `opacity 220ms ${EASE}, transform 320ms ${EASE_SETTLE}`,\n        },\n        to: {\n          opacity: active ? 1 : 0,\n          transform: active ? \"rotate(0deg) scale(1)\" : \"rotate(-90deg) scale(0.65)\",\n          transition: `opacity 220ms ${EASE}, transform 320ms ${EASE_SETTLE}`,\n        },\n      };\n    }\n\n    if (mode === \"flip\") {\n      return {\n        from: {\n          opacity: active ? 0 : 1,\n          transform: active ? \"rotateY(180deg) scale(0.7)\" : \"rotateY(0deg) scale(1)\",\n          transition: `opacity 180ms ${EASE}, transform 360ms ${EASE_SETTLE}`,\n        },\n        to: {\n          opacity: active ? 1 : 0,\n          transform: active ? \"rotateY(0deg) scale(1)\" : \"rotateY(-180deg) scale(0.7)\",\n          transition: `opacity 180ms ${EASE}, transform 360ms ${EASE_SETTLE}`,\n        },\n      };\n    }\n\n    // Default \"scale\" with soft blur\n    return {\n      from: {\n        opacity: active ? 0 : 1,\n        transform: active ? \"scale(0.55)\" : \"scale(1)\",\n        filter: active ? \"blur(1.5px)\" : \"blur(0px)\",\n        transition: `opacity 200ms ${EASE}, transform 300ms ${EASE_SETTLE}, filter 200ms ${EASE}`,\n      },\n      to: {\n        opacity: active ? 1 : 0,\n        transform: active ? \"scale(1)\" : \"scale(0.55)\",\n        filter: active ? \"blur(0px)\" : \"blur(1.5px)\",\n        transition: `opacity 200ms ${EASE}, transform 300ms ${EASE_SETTLE}, filter 200ms ${EASE}`,\n      },\n    };\n  };\n\n  const transforms = getTransforms();\n\n  return (\n    <span\n      className={`relative inline-flex items-center justify-center shrink-0 isolate select-none ${className}`}\n      style={{\n        width: size,\n        height: size,\n        perspective: mode === \"flip\" ? 600 : undefined,\n        ...style,\n      }}\n      aria-hidden=\"true\"\n      {...props}\n    >\n      <span\n        className=\"absolute inset-0 flex items-center justify-center will-change-transform\"\n        style={transforms.from}\n      >\n        {from}\n      </span>\n      <span\n        className=\"absolute inset-0 flex items-center justify-center will-change-transform\"\n        style={transforms.to}\n      >\n        {to}\n      </span>\n    </span>\n  );\n}\n\nconst buttonSizeStyles = {\n  sm: \"size-7 rounded-full text-[12px]\",\n  md: \"size-8.5 rounded-full text-[13px]\",\n  lg: \"size-10 rounded-full text-[14px]\",\n};\n\nconst iconSizes = {\n  sm: 13,\n  md: 15,\n  lg: 17,\n};\n\nconst buttonVariantStyles = {\n  outline:\n    \"bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent active:scale-[0.96]\",\n  ghost:\n    \"bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground active:scale-[0.96]\",\n  solid:\n    \"bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_1px_2px_rgba(48,93,222,0.18)] active:scale-[0.96]\",\n};\n\n/**\n * Clickable interactive button housing the IconMorph primitive.\n */\nexport function IconMorphButton({\n  from,\n  to,\n  active: controlledActive,\n  onActiveChange,\n  mode = \"scale\",\n  size = \"md\",\n  variant = \"outline\",\n  label,\n  className = \"\",\n  onClick,\n  ...props\n}: IconMorphButtonProps) {\n  const [internalActive, setInternalActive] = React.useState(false);\n  const active = controlledActive !== undefined ? controlledActive : internalActive;\n\n  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n    const next = !active;\n    if (controlledActive === undefined) {\n      setInternalActive(next);\n    }\n    onActiveChange?.(next);\n    onClick?.(e);\n  };\n\n  const sizeClass = buttonSizeStyles[size];\n  const variantClass = buttonVariantStyles[variant];\n  const iconPx = iconSizes[size];\n\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-pressed={active}\n      onClick={handleClick}\n      className={`inline-flex items-center justify-center cursor-pointer transition-colors duration-150 select-none ${sizeClass} ${variantClass} ${INTERACTION_FOCUS} ${className}`}\n      {...props}\n    >\n      <IconMorph from={from} to={to} active={active} mode={mode} size={iconPx} />\n    </button>\n  );\n}\n\nexport default IconMorph;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/icon-morph. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}