{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "icon-morph",
  "type": "registry:ui",
  "title": "Icon morph",
  "description": "Icons that morph between states: menu to close, play to pause, copy to check, and more.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/icon-morph/icon-morph.tsx",
      "content": "\"use client\";\n\nimport { useId, useState, useSyncExternalStore } from \"react\";\nimport type { ReactNode, SVGProps } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type IconMorphName = \"menu\" | \"play\" | \"plus\" | \"copy\" | \"theme\" | \"chevron\" | \"bell\" | \"heart\";\n\nexport interface IconMorphIconProps extends Omit<SVGProps<SVGSVGElement>, \"ref\" | \"children\"> {\n  /**\n   * The second state of the icon. menu: close, play: pause, plus: check, copy: check,\n   * theme: moon, chevron: turned, bell: notifications on, heart: liked.\n   */\n  state?: boolean;\n  /** Rendered size in px. Defaults to 24. */\n  size?: number;\n  /** Stroke width in the 24 unit grid. Defaults to 1.75. */\n  strokeWidth?: number;\n}\n\n/**\n * A family of icons that morph between two states instead of swapping. Lines rotate and slide into place, shapes interpolate\n * their outlines, and a few carry a small moment of their own: the bell rings when it turns on and the heart bursts when liked.\n * Every morph rides an interruptible spring, so toggling quickly reverses from wherever the icon is. The icons are decorative;\n * put them inside a button that carries the accessible name and `aria-pressed`.\n */\nexport interface IconMorphProps extends IconMorphIconProps {\n  name: IconMorphName;\n}\n\nconst { spring } = motionTokens;\nconst instant: Transition = { duration: 0 };\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\nfunction useIds() {\n  return useId().replace(/[^a-zA-Z0-9-]/g, \"\");\n}\n/** Counts how many times `state` turned true after mount, so one-shot moments (a ring, a burst) replay on each turn on. */\nfunction useTurnOns(state: boolean) {\n  const [previous, setPrevious] = useState(state);\n  const [count, setCount] = useState(0);\n  if (previous !== state) {\n    setPrevious(state);\n    if (state) setCount((value) => value + 1);\n  }\n  return count;\n}\n\nfunction Frame({\n  size = 24,\n  strokeWidth = 1.75,\n  className,\n  children,\n  ...rest\n}: IconMorphIconProps & { children: ReactNode }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      width={size}\n      height={size}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={strokeWidth}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      className={cn(\"block flex-none overflow-visible\", className)}\n      {...rest}\n    >\n      {children}\n    </svg>\n  );\n}\n\n/** Menu to close: the outer lines slide to the middle as they rotate into an X, and the middle line folds away. */\nexport function MenuIcon({ state = false, ...props }: IconMorphIconProps) {\n  const reduced = useReducedFlag();\n  const t = reduced ? instant : spring.morph;\n  return (\n    <Frame {...props}>\n      <motion.path\n        d=\"M4 6h16\"\n        initial={false}\n        animate={state ? { y: 6, rotate: 45 } : { y: 0, rotate: 0 }}\n        transition={t}\n      />\n      <motion.path\n        d=\"M4 12h16\"\n        initial={false}\n        animate={state ? { opacity: 0, scaleX: 0.2 } : { opacity: 1, scaleX: 1 }}\n        transition={reduced ? instant : { ...spring.snappy, opacity: { duration: motionTokens.duration.fast } }}\n      />\n      <motion.path\n        d=\"M4 18h16\"\n        initial={false}\n        animate={state ? { y: -6, rotate: -45 } : { y: 0, rotate: 0 }}\n        transition={t}\n      />\n    </Frame>\n  );\n}\n\n// Play is split down the middle into two quads, so each half can become one pause bar.\nconst PLAY = [\"M7 4.5 L12 7.42 L12 16.58 L7 19.5 Z\", \"M12 7.42 L19.5 11.8 L19.5 12.2 L12 16.58 Z\"];\nconst PAUSE = [\"M6.5 5 L10 5 L10 19 L6.5 19 Z\", \"M14 5 L17.5 5 L17.5 19 L14 19 Z\"];\n\n/** Play to pause: the triangle splits into two halves that straighten into bars. */\nexport function PlayPauseIcon({ state = false, ...props }: IconMorphIconProps) {\n  const reduced = useReducedFlag();\n  const t = reduced ? instant : spring.morph;\n  return (\n    <Frame {...props}>\n      {PLAY.map((d, index) => (\n        <motion.path\n          key={index}\n          d={d}\n          fill=\"currentColor\"\n          strokeWidth={1.5}\n          initial={false}\n          animate={{ d: state ? PAUSE[index] : d }}\n          transition={t}\n        />\n      ))}\n    </Frame>\n  );\n}\n\n/** Plus to check: each bar swings its ends to become one leg of the check. */\nexport function PlusCheckIcon({ state = false, ...props }: IconMorphIconProps) {\n  const reduced = useReducedFlag();\n  const t = reduced ? instant : spring.morph;\n  return (\n    <Frame {...props}>\n      <motion.path\n        d=\"M12 5 L12 19\"\n        initial={false}\n        animate={{ d: state ? \"M20 6.5 L9.5 17\" : \"M12 5 L12 19\" }}\n        transition={t}\n      />\n      <motion.path\n        d=\"M5 12 L19 12\"\n        initial={false}\n        animate={{ d: state ? \"M4.5 12 L9.5 17\" : \"M5 12 L19 12\" }}\n        transition={t}\n      />\n    </Frame>\n  );\n}\n\n/** Copy to check: the back sheet tucks under the front one, the front one settles away, and the check draws in. */\nexport function CopyCheckIcon({ state = false, ...props }: IconMorphIconProps) {\n  const reduced = useReducedFlag();\n  const t = reduced ? instant : spring.smooth;\n  const fade = reduced ? instant : { duration: motionTokens.duration.fast };\n  return (\n    <Frame {...props}>\n      <motion.path\n        d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"\n        initial={false}\n        animate={state ? { x: 4, y: 4, opacity: 0 } : { x: 0, y: 0, opacity: 1 }}\n        transition={{ ...t, opacity: fade }}\n      />\n      <motion.rect\n        x=\"8\"\n        y=\"8\"\n        width=\"14\"\n        height=\"14\"\n        rx=\"2\"\n        initial={false}\n        animate={state ? { scale: 0.7, opacity: 0 } : { scale: 1, opacity: 1 }}\n        transition={{ ...t, opacity: fade }}\n      />\n      <motion.path\n        d=\"M5 12.5 L9.5 17 L19 7.5\"\n        initial={false}\n        animate={state ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}\n        transition={\n          reduced\n            ? instant\n            : { ...spring.smooth, delay: state ? 0.06 : 0, opacity: { duration: 0.08, delay: state ? 0.06 : 0 } }\n        }\n      />\n    </Frame>\n  );\n}\n\nconst RAYS = Array.from({ length: 8 }, (_, index) => {\n  const angle = (index * Math.PI) / 4,\n    c = Math.cos(angle),\n    s = Math.sin(angle);\n  const r = (value: number) => Math.round(value * 100) / 100;\n  return `M${r(12 + c * 7.5)} ${r(12 + s * 7.5)} L${r(12 + c * 10)} ${r(12 + s * 10)}`;\n});\n\n/**\n * Sun to moon: the disc grows while a shadow slides across it from the corner, carving the crescent, and the rays turn and fold in.\n * The crescent is drawn as an outline: the disc's edge outside the shadow, and the shadow's edge inside the disc.\n */\nexport function SunMoonIcon({ state = false, ...props }: IconMorphIconProps) {\n  const reduced = useReducedFlag();\n  const id = useIds();\n  const t = reduced ? instant : spring.morph;\n  const disc = { r: state ? 8.5 : 4.5 };\n  const shadow = state ? { cx: 18, cy: 6, r: 6.5 } : { cx: 30, cy: -6, r: 6.5 };\n  return (\n    <Frame {...props}>\n      <defs>\n        <mask id={`${id}-bite`} maskUnits=\"userSpaceOnUse\" x=\"-8\" y=\"-8\" width=\"40\" height=\"40\">\n          <rect x=\"-8\" y=\"-8\" width=\"40\" height=\"40\" fill=\"white\" stroke=\"none\" />\n          <motion.circle\n            cx={30}\n            cy={-6}\n            r={6.5}\n            fill=\"black\"\n            stroke=\"none\"\n            initial={false}\n            animate={shadow}\n            transition={t}\n          />\n        </mask>\n        <clipPath id={`${id}-disc`}>\n          <motion.circle cx={12} cy={12} r={4.5} initial={false} animate={disc} transition={t} />\n        </clipPath>\n      </defs>\n      <motion.circle cx={12} cy={12} r={4.5} mask={`url(#${id}-bite)`} initial={false} animate={disc} transition={t} />\n      <motion.circle\n        cx={30}\n        cy={-6}\n        r={6.5}\n        clipPath={`url(#${id}-disc)`}\n        initial={false}\n        animate={shadow}\n        transition={t}\n      />\n      <motion.g\n        initial={false}\n        animate={state ? { rotate: 90, scale: 0.4, opacity: 0 } : { rotate: 0, scale: 1, opacity: 1 }}\n        transition={reduced ? instant : { ...spring.morph, opacity: { duration: motionTokens.duration.fast } }}\n      >\n        {RAYS.map((d) => (\n          <path key={d} d={d} />\n        ))}\n      </motion.g>\n    </Frame>\n  );\n}\n\nexport interface ChevronIconProps extends IconMorphIconProps {\n  /** Degrees the chevron turns when `state` is true. 180 flips down to up; 90 turns right to down. */\n  turn?: number;\n  /** Where the chevron points at rest. */\n  pointing?: \"down\" | \"right\";\n}\n\n/** A chevron that turns on a spring, for disclosure and sort controls. */\nexport function ChevronIcon({ state = false, turn = 180, pointing = \"down\", ...props }: ChevronIconProps) {\n  const reduced = useReducedFlag();\n  return (\n    <Frame {...props}>\n      <motion.path\n        d={pointing === \"down\" ? \"M6 9 L12 15 L18 9\" : \"M9 6 L15 12 L9 18\"}\n        initial={false}\n        animate={{ rotate: state ? turn : 0 }}\n        style={{ transformOrigin: \"50% 50%\" }}\n        transition={reduced ? instant : spring.morph}\n      />\n    </Frame>\n  );\n}\n\nconst BELL = \"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9\";\nconst CLAPPER = \"M10.3 21a1.94 1.94 0 0 0 3.4 0\";\n\n/** Notifications on and off: turning on rings the bell from its top, with the clapper lagging behind; turning off strikes it through. */\nexport function BellIcon({ state = false, ...props }: IconMorphIconProps) {\n  const reduced = useReducedFlag();\n  const id = useIds();\n  const rings = useTurnOns(state);\n  const ring = rings > 0 && !reduced;\n  const slash = reduced ? instant : { ...spring.smooth, opacity: { duration: 0.08 } };\n  const slashOn = { pathLength: state ? 0 : 1, opacity: state ? 0 : 1 };\n  return (\n    <Frame {...props}>\n      <defs>\n        <mask id={`${id}-cut`} maskUnits=\"userSpaceOnUse\" x=\"-4\" y=\"-4\" width=\"32\" height=\"32\">\n          <rect x=\"-4\" y=\"-4\" width=\"32\" height=\"32\" fill=\"white\" stroke=\"none\" />\n          <motion.path\n            d=\"M3 3 L21 21\"\n            stroke=\"black\"\n            strokeWidth={5}\n            initial={false}\n            animate={slashOn}\n            transition={slash}\n          />\n        </mask>\n      </defs>\n      <g mask={`url(#${id}-cut)`}>\n        <motion.g\n          key={`bell-${rings}`}\n          style={{ transformOrigin: \"50% 0%\" }}\n          animate={ring ? { rotate: [0, 16, -13, 9, -6, 3, 0] } : { rotate: 0 }}\n          transition={{ duration: 0.9, ease: \"easeOut\" }}\n        >\n          <path d={BELL} />\n        </motion.g>\n        <motion.path\n          key={`clapper-${rings}`}\n          d={CLAPPER}\n          animate={ring ? { x: [0, -2.2, 2, -1.4, 0.9, -0.4, 0] } : { x: 0 }}\n          transition={{ duration: 0.9, ease: \"easeOut\", delay: 0.04 }}\n        />\n      </g>\n      <motion.path d=\"M3 3 L21 21\" initial={false} animate={slashOn} transition={slash} />\n    </Frame>\n  );\n}\n\nconst HEART =\n  \"M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z\";\nconst SPARKS = Array.from({ length: 7 }, (_, index) => {\n  const angle = -Math.PI / 2 + (index * 2 * Math.PI) / 7;\n  return { x: Math.cos(angle), y: Math.sin(angle), r: index % 2 ? 1 : 1.4 };\n});\n\n/** Like: the heart fills with a pop, and a ring and a few sparks burst out once. Unliking simply empties it. */\nexport function HeartIcon({ state = false, className, ...props }: IconMorphIconProps) {\n  const reduced = useReducedFlag();\n  const likes = useTurnOns(state);\n  const burst = likes > 0 && state && !reduced;\n  return (\n    <Frame\n      {...props}\n      className={cn(\n        \"transition-[color] duration-fast ease-standard data-on:text-(--icon-morph-like,var(--red))\",\n        className,\n      )}\n      data-on={state ? \"\" : undefined}\n      overflow=\"visible\"\n    >\n      {burst && (\n        <g key={`burst-${likes}`} stroke=\"none\">\n          <motion.circle\n            cx={12}\n            cy={12}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            initial={{ r: 4, strokeWidth: 3, opacity: 0.5 }}\n            animate={{ r: 13, strokeWidth: 0, opacity: 0 }}\n            transition={{ duration: 0.5, ease: [...motionTokens.ease.enter] }}\n          />\n          {SPARKS.map((spark, index) => (\n            <motion.circle\n              key={index}\n              cx={12}\n              cy={12}\n              r={spark.r}\n              fill=\"currentColor\"\n              initial={{ x: spark.x * 7, y: spark.y * 7, opacity: 1, scale: 1 }}\n              animate={{ x: spark.x * 14, y: spark.y * 14, opacity: 0, scale: 0.3 }}\n              transition={{ duration: 0.55, delay: 0.05, ease: [...motionTokens.ease.enter] }}\n            />\n          ))}\n        </g>\n      )}\n      <motion.g\n        key={`heart-${likes}`}\n        style={{ transformOrigin: \"50% 50%\" }}\n        animate={burst ? { scale: [0.72, 1.14, 1] } : { scale: 1 }}\n        transition={{ duration: 0.45, ease: [...motionTokens.ease.enter] }}\n      >\n        <motion.path\n          d={HEART}\n          initial={{ fillOpacity: state && !burst ? 1 : 0 }}\n          animate={{ fillOpacity: state ? 1 : 0 }}\n          fill=\"currentColor\"\n          transition={reduced ? instant : { duration: motionTokens.duration.fast }}\n        />\n      </motion.g>\n    </Frame>\n  );\n}\n\nconst ICONS: Record<IconMorphName, (props: IconMorphIconProps) => ReactNode> = {\n  menu: MenuIcon,\n  play: PlayPauseIcon,\n  plus: PlusCheckIcon,\n  copy: CopyCheckIcon,\n  theme: SunMoonIcon,\n  chevron: ChevronIcon,\n  bell: BellIcon,\n  heart: HeartIcon,\n};\n\n/** One entry point for every morphing icon: pick it by `name` and drive it with `state`. */\nexport function IconMorph({ name, ...props }: IconMorphProps) {\n  const Icon = ICONS[name];\n  return <Icon {...props} />;\n}\n\nexport default IconMorph;\n",
      "type": "registry:component",
      "target": "@components/iris/icon-morph/icon-morph.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/icon-morph",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "IconMorph",
    "tags": [
      "actions",
      "new"
    ],
    "docs": "https://irisui.dev/components/icon-morph"
  }
}
