{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/icon-morph/icon-morph.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./icon-morph.module.css\";\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(subscribe, () => true, () => false);\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({ size = 24, strokeWidth = 1.75, className, children, ...rest }: IconMorphIconProps & { children: ReactNode }) {\n  return <svg viewBox=\"0 0 24 24\" width={size} height={size} fill=\"none\" stroke=\"currentColor\" strokeWidth={strokeWidth}\n    strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"\n    className={[styles.icon, className].filter(Boolean).join(\" \")} {...rest}>{children}</svg>;\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 <Frame {...props}>\n    <motion.path d=\"M4 6h16\" initial={false} animate={state ? { y: 6, rotate: 45 } : { y: 0, rotate: 0 }} transition={t} />\n    <motion.path d=\"M4 12h16\" initial={false} animate={state ? { opacity: 0, scaleX: .2 } : { opacity: 1, scaleX: 1 }}\n      transition={reduced ? instant : { ...spring.snappy, opacity: { duration: motionTokens.duration.fast } }} />\n    <motion.path d=\"M4 18h16\" initial={false} animate={state ? { y: -6, rotate: -45 } : { y: 0, rotate: 0 }} transition={t} />\n  </Frame>;\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 <Frame {...props}>\n    {PLAY.map((d, index) => <motion.path key={index} d={d} fill=\"currentColor\" strokeWidth={1.5} initial={false}\n      animate={{ d: state ? PAUSE[index] : d }} transition={t} />)}\n  </Frame>;\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 <Frame {...props}>\n    <motion.path d=\"M12 5 L12 19\" initial={false} animate={{ d: state ? \"M20 6.5 L9.5 17\" : \"M12 5 L12 19\" }} transition={t} />\n    <motion.path d=\"M5 12 L19 12\" initial={false} animate={{ d: state ? \"M4.5 12 L9.5 17\" : \"M5 12 L19 12\" }} transition={t} />\n  </Frame>;\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 <Frame {...props}>\n    <motion.path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" initial={false}\n      animate={state ? { x: 4, y: 4, opacity: 0 } : { x: 0, y: 0, opacity: 1 }} transition={{ ...t, opacity: fade }} />\n    <motion.rect x=\"8\" y=\"8\" width=\"14\" height=\"14\" rx=\"2\" initial={false}\n      animate={state ? { scale: .7, opacity: 0 } : { scale: 1, opacity: 1 }} transition={{ ...t, opacity: fade }} />\n    <motion.path d=\"M5 12.5 L9.5 17 L19 7.5\" initial={false}\n      animate={state ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}\n      transition={reduced ? instant : { ...spring.smooth, delay: state ? .06 : 0, opacity: { duration: .08, delay: state ? .06 : 0 } }} />\n  </Frame>;\n}\n\nconst RAYS = Array.from({ length: 8 }, (_, index) => {\n  const angle = index * Math.PI / 4, c = Math.cos(angle), 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 <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 cx={30} cy={-6} r={6.5} fill=\"black\" stroke=\"none\" initial={false} animate={shadow} transition={t} />\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 cx={30} cy={-6} r={6.5} clipPath={`url(#${id}-disc)`} initial={false} animate={shadow} transition={t} />\n    <motion.g initial={false} animate={state ? { rotate: 90, scale: .4, opacity: 0 } : { rotate: 0, scale: 1, opacity: 1 }}\n      transition={reduced ? instant : { ...spring.morph, opacity: { duration: motionTokens.duration.fast } }}>\n      {RAYS.map(d => <path key={d} d={d} />)}\n    </motion.g>\n  </Frame>;\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 <Frame {...props}>\n    <motion.path d={pointing === \"down\" ? \"M6 9 L12 15 L18 9\" : \"M9 6 L15 12 L9 18\"} initial={false}\n      animate={{ rotate: state ? turn : 0 }} style={{ transformOrigin: \"50% 50%\" }} transition={reduced ? instant : spring.morph} />\n  </Frame>;\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: .08 } };\n  const slashOn = { pathLength: state ? 0 : 1, opacity: state ? 0 : 1 };\n  return <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 d=\"M3 3 L21 21\" stroke=\"black\" strokeWidth={5} initial={false} animate={slashOn} transition={slash} />\n      </mask>\n    </defs>\n    <g mask={`url(#${id}-cut)`}>\n      <motion.g key={`bell-${rings}`} style={{ transformOrigin: \"50% 0%\" }}\n        animate={ring ? { rotate: [0, 16, -13, 9, -6, 3, 0] } : { rotate: 0 }} transition={{ duration: .9, ease: \"easeOut\" }}>\n        <path d={BELL} />\n      </motion.g>\n      <motion.path key={`clapper-${rings}`} d={CLAPPER}\n        animate={ring ? { x: [0, -2.2, 2, -1.4, .9, -.4, 0] } : { x: 0 }} transition={{ duration: .9, ease: \"easeOut\", delay: .04 }} />\n    </g>\n    <motion.path d=\"M3 3 L21 21\" initial={false} animate={slashOn} transition={slash} />\n  </Frame>;\n}\n\nconst HEART = \"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 <Frame {...props} className={[styles.heart, className].filter(Boolean).join(\" \")} data-on={state ? \"\" : undefined} overflow=\"visible\">\n    {burst && <g key={`burst-${likes}`} stroke=\"none\">\n      <motion.circle cx={12} cy={12} fill=\"none\" stroke=\"currentColor\" initial={{ r: 4, strokeWidth: 3, opacity: .5 }}\n        animate={{ r: 13, strokeWidth: 0, opacity: 0 }} transition={{ duration: .5, ease: [...motionTokens.ease.enter] }} />\n      {SPARKS.map((spark, index) => <motion.circle key={index} cx={12} cy={12} r={spark.r} 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: .3 }}\n        transition={{ duration: .55, delay: .05, ease: [...motionTokens.ease.enter] }} />)}\n    </g>}\n    <motion.g key={`heart-${likes}`} style={{ transformOrigin: \"50% 50%\" }}\n      animate={burst ? { scale: [.72, 1.14, 1] } : { scale: 1 }} transition={{ duration: .45, ease: [...motionTokens.ease.enter] }}>\n      <motion.path d={HEART} initial={{ fillOpacity: state && !burst ? 1 : 0 }} animate={{ fillOpacity: state ? 1 : 0 }} fill=\"currentColor\"\n        transition={reduced ? instant : { duration: motionTokens.duration.fast }} />\n    </motion.g>\n  </Frame>;\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"
    },
    {
      "path": "registry/components/icon-morph/icon-morph.module.css",
      "type": "registry:component",
      "target": "@components/xzero/icon-morph/icon-morph.module.css",
      "content": ".icon { display: block; flex: none; overflow: visible; }\n\n/* Liked hearts take the like color; it defaults to the danger role, the conventional red of a like. */\n.heart { transition: color var(--duration-fast) var(--ease-standard); }\n.heart[data-on] { color: var(--icon-morph-like, var(--danger)); }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/icon-morph",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "actions",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/icon-morph",
    "markdown": "https://ui.xzero.co.uk/components/icon-morph/markdown"
  }
}