{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "interactive-pets",
  "title": "Interactive Pets",
  "description": "A playful draggable cat with feeding reactions and idle animations.",
  "dependencies": [
    "framer-motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/spark-ui/interactive-pets.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  AnimatePresence,\n  motion,\n  useMotionValue,\n} from \"framer-motion\";\nimport React from \"react\";\n\nexport type PetType = \"cat\";\n\nexport type PetConfig = {\n  id: PetType;\n  name?: string;\n  initialPosition?: { x: number; y: number };\n  idleMessage?: string | string[];\n  fedMessage?: string;\n  fullMessage?: string;\n};\n\nexport type InteractivePetsProps = {\n  pets?: PetConfig[];\n  className?: string;\n  playgroundClassName?: string;\n  showInstructions?: boolean;\n  instructionText?: string;\n  onPetMove?: (pet: PetType, position: { x: number; y: number }) => void;\n  onPetFeed?: (pet: PetType) => void;\n};\n\nconst PET_DEFAULTS: Record<\n  PetType,\n  Required<Omit<PetConfig, \"id\">> & { food: string }\n> = {\n  cat: {\n    name: \"Mochi\",\n    initialPosition: { x: 24, y: 64 },\n    idleMessage: [\"do not perceive me.\", \"busy napping.\", \"you may proceed.\"],\n    fedMessage: \"purr. accepted.\",\n    fullMessage: \"i'm full.\",\n    food: \"bone\",\n  },\n};\n\nconst DEFAULT_PETS: PetConfig[] = [{ id: \"cat\" }];\n\nconst DEFAULT_INSTRUCTIONS =\n  \"Drag the cat anywhere, click it to chat, or tap the bone to feed it.\";\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), Math.max(min, max));\n\n/* ------------------------------- Artwork -------------------------------- */\n\ntype SpriteProps = { idle: boolean; className?: string; draggable?: boolean };\n\nexport function CatSprite({ idle, className, draggable }: SpriteProps) {\n  const [mounted, setMounted] = React.useState(false);\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setMounted(true));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const animateIdle = idle && mounted;\n\n  return (\n    <motion.svg\n      viewBox=\"0 0 736 736\"\n      fill=\"currentColor\"\n      className={cn(\n        \"text-black dark:text-white\",\n        draggable && \"z-20 cursor-grab active:cursor-grabbing\",\n        className,\n      )}\n      drag={draggable}\n      dragMomentum={false}\n      aria-hidden\n      initial={false}\n      animate={\n        animateIdle\n          ? {\n              rotate: [0, 3, -3, 2, 0],\n              y: [0, -2, 0, -1, 0],\n              scaleX: [1, 0.98, 1.02, 0.99, 1],\n            }\n          : { rotate: 0, y: 0, scaleX: 1 }\n      }\n      transition={\n        animateIdle\n          ? {\n              duration: 3.6,\n              repeat: Infinity,\n              repeatDelay: 2.4,\n              ease: \"easeInOut\",\n            }\n          : { duration: 0.3 }\n      }\n      style={{ transformOrigin: \"50% 100%\", touchAction: \"none\" }}\n    >\n      <path\n        d=\"M189.517822,321.560608 C200.482208,304.250854 213.569870,289.241943 228.417297,275.677063 C231.700211,272.677704 233.908188,272.668304 237.240265,275.544403 C251.363831,287.735077 263.243622,301.821564 273.252991,317.492340 C275.388580,320.835846 277.180511,322.046173 281.215698,320.271118 C289.102448,316.801788 297.688904,315.932587 306.168793,316.193054 C311.751160,316.364532 314.345825,314.267792 316.685059,309.449768 C326.817535,288.580505 343.818115,273.918915 362.072845,260.483154 C364.738983,258.520874 366.633301,259.996460 368.730408,261.570160 C384.972260,273.758148 396.550598,289.624481 405.305267,307.733612 C406.029816,309.232361 406.872986,310.709686 407.334869,312.292206 C408.362427,315.812592 410.704742,316.672638 414.140076,316.646301 C457.275818,316.315521 492.104858,333.735413 519.660156,366.424438 C521.987671,369.185608 523.626282,370.497070 526.737854,367.332062 C543.566895,350.213959 546.994324,321.303497 534.473572,300.671021 C520.595276,277.801514 498.410797,274.791260 478.847137,293.160950 C474.353638,297.380219 470.198822,301.985413 465.506989,305.963501 C452.035034,317.386108 434.044373,315.581299 424.223114,302.058319 C418.794006,294.583008 418.473602,286.021088 420.241577,277.616516 C427.739410,241.973709 463.006561,218.618759 499.185364,223.235352 C542.669983,228.784195 579.665894,265.549377 585.673340,310.163574 C591.080627,350.320282 578.193237,384.391357 543.902283,408.453033 C541.014954,410.479095 541.191895,412.450226 542.019104,415.255981 C546.758179,431.330841 548.678711,447.726868 548.446838,464.522064 C547.780396,512.798340 520.568359,547.439331 473.686035,559.256287 C452.688141,564.548950 431.232727,565.749634 409.767487,565.991089 C373.609436,566.397888 337.436127,566.541809 301.283356,565.944336 C276.520020,565.535156 251.619354,565.542114 227.205750,560.289734 C209.064545,556.386780 191.968567,550.111145 178.576675,536.465393 C161.968781,519.542786 153.705139,498.704590 151.794479,475.513580 C148.045746,430.012787 157.361694,386.809082 176.467438,345.571259 C180.248489,337.410248 184.471130,329.461243 189.517822,321.560608 M330.370453,485.989319 C330.982941,487.704437 331.455017,489.486816 332.237244,491.120697 C333.862885,494.516357 335.967255,497.551300 340.217285,497.707733 C344.509766,497.865662 346.896606,495.011536 348.491425,491.588287 C353.026337,481.854340 353.042419,472.006104 348.359192,462.329956 C346.816864,459.143402 344.457550,456.320709 340.423157,456.416687 C336.237549,456.516205 333.885437,459.438690 332.297974,462.855865 C328.971802,470.015717 328.373444,477.502502 330.370453,485.989319 M228.754074,453.494293 C224.649353,450.907654 221.235672,451.864288 218.294373,455.483521 C211.502075,463.841461 211.602661,481.936035 218.505981,490.088348 C222.433090,494.725952 226.760406,494.688110 230.593018,489.982544 C238.107025,480.757294 237.477631,462.793579 228.754074,453.494293 M185.266632,481.237305 C190.100891,484.717743 194.912292,488.230743 199.787613,491.652710 C200.831985,492.385773 202.198288,493.238708 203.286743,491.757904 C203.960617,490.841095 203.502029,489.811676 202.617081,489.163788 C196.611649,484.766998 190.597641,480.381622 184.554657,476.036713 C183.684921,475.411346 182.610977,475.364532 181.900116,476.328613 C181.194717,477.285248 181.564438,478.213470 182.396759,478.942688 C183.142303,479.595917 183.930374,480.200623 185.266632,481.237305 M373.679810,497.526978 C374.764648,501.207062 376.887177,498.834412 378.130859,497.989166 C383.042450,494.650940 387.798279,491.078949 392.538971,487.497131 C393.632935,486.670532 395.119263,485.746948 393.523987,482.761353 C386.451477,486.836151 380.008362,491.619965 373.679810,497.526978 M209.143570,507.736420 C204.260498,505.080231 186.867783,505.785706 184.051880,509.923279 C192.120544,509.923279 199.430801,509.938873 206.740845,509.905365 C207.760666,509.900696 208.693054,509.512878 209.143570,507.736420 M387.568939,513.476257 C380.919128,513.858154 374.090515,512.246521 366.935150,514.712891 C371.742706,518.260986 387.174500,518.410400 391.905121,515.468567 C391.361969,513.580139 389.616943,513.922363 387.568939,513.476257 z\"\n      />\n      <motion.g\n        initial={false}\n        animate={animateIdle ? { opacity: [0, 0, 1, 0] } : { opacity: 0 }}\n        transition={\n          animateIdle\n            ? {\n                duration: 0.22,\n                repeat: Infinity,\n                repeatDelay: 3.4,\n                times: [0, 0.35, 0.5, 1],\n              }\n            : { duration: 0.1 }\n        }\n      >\n        <ellipse cx=\"228\" cy=\"476\" rx=\"13\" ry=\"24\" />\n        <ellipse cx=\"340\" cy=\"477\" rx=\"13\" ry=\"24\" />\n        <path\n          d=\"M216 477h24M328 478h24\"\n          fill=\"none\"\n          stroke=\"var(--background)\"\n          strokeWidth=\"7\"\n          strokeLinecap=\"round\"\n        />\n      </motion.g>\n    </motion.svg>\n  );\n}\n\nconst SPRITES: Record<PetType, React.ComponentType<SpriteProps>> = {\n  cat: CatSprite,\n};\n\nconst FOOD_EMOJIS: Record<PetType, string> = {\n  cat: \"🦴\",\n};\n\n/* -------------------------------- Effects ------------------------------- */\n\nconst CRUMB_OFFSETS = [\n  { x: -14, y: -6 },\n  { x: -6, y: 10 },\n  { x: 4, y: -12 },\n  { x: 12, y: 6 },\n  { x: 18, y: -4 },\n];\n\nfunction Crumbs() {\n  return (\n    <div className=\"pointer-events-none absolute left-[38%] top-[68%]\">\n      {CRUMB_OFFSETS.map((offset, i) => (\n        <motion.span\n          key={i}\n          className=\"absolute size-1 rounded-full bg-muted-foreground/70\"\n          initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}\n          animate={{ x: offset.x, y: offset.y, opacity: 0, scale: 0.4 }}\n          transition={{ duration: 0.7, delay: i * 0.04, ease: \"easeOut\" }}\n        />\n      ))}\n    </div>\n  );\n}\n\n/* --------------------------------- Pet ----------------------------------- */\n\ntype ResolvedPet = Required<PetConfig> & { food: string };\n\nconst FULL_AFTER_FEEDS = 3;\n\nconst getFeedMessage = (pet: ResolvedPet, feedCount: number) =>\n  feedCount >= FULL_AFTER_FEEDS ? pet.fullMessage : pet.fedMessage;\n\ntype PetActorProps = {\n  pet: ResolvedPet;\n  playgroundRef: React.RefObject<HTMLDivElement | null>;\n  reduceMotion: boolean;\n  feedCount: number;\n  reactionVisible: boolean;\n  registerRef: (el: HTMLDivElement | null) => void;\n  onMove: (position: { x: number; y: number }) => void;\n};\n\nfunction PetActor({\n  pet,\n  playgroundRef,\n  reduceMotion,\n  feedCount,\n  reactionVisible,\n  registerRef,\n  onMove,\n}: PetActorProps) {\n  const ref = React.useRef<HTMLDivElement | null>(null);\n  const x = useMotionValue(pet.initialPosition.x);\n  const y = useMotionValue(pet.initialPosition.y);\n  const [dragging, setDragging] = React.useState(false);\n  const [showIdleBubble, setShowIdleBubble] = React.useState(false);\n  const idleMessages = Array.isArray(pet.idleMessage)\n    ? pet.idleMessage\n    : [pet.idleMessage];\n  const [idleMessage, setIdleMessage] = React.useState(idleMessages[0]);\n  const idleTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  const [fx, setFx] = React.useState<{\n    type: \"settle\" | \"bounce\";\n    id: number;\n  } | null>(null);\n\n  const Sprite = SPRITES[pet.id];\n  const idle = true;\n\n  // Bounce when a feeding lands (skip mount).\n  const prevFeedCount = React.useRef(feedCount);\n  React.useEffect(() => {\n    if (feedCount > prevFeedCount.current) {\n      setFx({ type: \"bounce\", id: feedCount });\n    }\n    prevFeedCount.current = feedCount;\n  }, [feedCount]);\n\n  // Keep the pet inside the playground when it resizes.\n  React.useEffect(() => {\n    const el = ref.current;\n    const container = playgroundRef.current;\n    if (!el || !container || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      x.set(clamp(x.get(), 0, container.clientWidth - el.offsetWidth));\n      y.set(clamp(y.get(), 0, container.clientHeight - el.offsetHeight));\n    });\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, [playgroundRef, x, y]);\n\n  React.useEffect(() => () => clearTimeout(idleTimer.current), []);\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      showRandomMessage();\n      return;\n    }\n    const step = event.shiftKey ? 24 : 8;\n    const delta = {\n      ArrowLeft: { dx: -step, dy: 0 },\n      ArrowRight: { dx: step, dy: 0 },\n      ArrowUp: { dx: 0, dy: -step },\n      ArrowDown: { dx: 0, dy: step },\n    }[event.key];\n    if (!delta) return;\n    event.preventDefault();\n    const el = ref.current;\n    const container = playgroundRef.current;\n    if (!el || !container) return;\n    x.set(clamp(x.get() + delta.dx, 0, container.clientWidth - el.offsetWidth));\n    y.set(\n      clamp(y.get() + delta.dy, 0, container.clientHeight - el.offsetHeight),\n    );\n    onMove({ x: Math.round(x.get()), y: Math.round(y.get()) });\n  };\n\n  const showRandomMessage = () => {\n    setIdleMessage(\n      idleMessages[Math.floor(Math.random() * idleMessages.length)] ?? \"\",\n    );\n    setShowIdleBubble(true);\n    clearTimeout(idleTimer.current);\n    idleTimer.current = setTimeout(() => setShowIdleBubble(false), 2400);\n  };\n\n  return (\n    <motion.div\n      data-pet-actor\n      ref={(el) => {\n        ref.current = el;\n        registerRef(el);\n      }}\n      drag\n      dragConstraints={playgroundRef}\n      dragElastic={0}\n      dragMomentum={false}\n      whileDrag={{ scale: 1.1 }}\n      onDragStart={() => setDragging(true)}\n      onDragEnd={() => {\n        setDragging(false);\n        if (!reduceMotion) setFx({ type: \"settle\", id: Date.now() });\n        onMove({ x: Math.round(x.get()), y: Math.round(y.get()) });\n      }}\n      onClick={showRandomMessage}\n      onKeyDown={handleKeyDown}\n      tabIndex={0}\n      role=\"button\"\n      aria-roledescription=\"draggable pet\"\n      aria-label={`${pet.name} the ${pet.id}. Click to chat, drag to move, or use arrow keys.`}\n      style={{ x, y, touchAction: \"none\" }}\n      className={cn(\n        \"pointer-events-auto absolute left-0 top-0 select-none rounded-2xl outline-none\",\n        \"cursor-grab active:cursor-grabbing\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        dragging && \"z-10\",\n      )}\n    >\n      <AnimatePresence>\n        {(reactionVisible || showIdleBubble) && (\n          <motion.div\n            key={reactionVisible ? `fed-${feedCount}` : \"idle\"}\n            initial={\n              reduceMotion ? { opacity: 1 } : { opacity: 0, y: 6, scale: 0.9 }\n            }\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={\n              reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.95 }\n            }\n            transition={{ duration: reduceMotion ? 0 : 0.2 }}\n            className=\"pointer-events-none absolute -top-4 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-full border border-border bg-popover px-2.5 py-1 text-[11px] leading-none text-popover-foreground shadow-sm\"\n          >\n            {reactionVisible ? getFeedMessage(pet, feedCount) : idleMessage}\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <motion.div\n        key={fx?.id ?? \"rest\"}\n        animate={\n          fx && !reduceMotion\n            ? fx.type === \"bounce\"\n              ? { scale: [1, 1.18, 0.94, 1], rotate: [0, -4, 4, 0] }\n              : { scaleY: [1, 0.82, 1.06, 1], scaleX: [1, 1.1, 0.97, 1] }\n            : undefined\n        }\n        transition={{ duration: 0.45, ease: \"easeOut\" }}\n        className=\"origin-bottom\"\n      >\n        <Sprite\n          idle={idle}\n          className={cn(\n            \"h-14 w-14 sm:h-16 sm:w-16\",\n          )}\n        />\n      </motion.div>\n      {feedCount > 0 && !reduceMotion && (\n        <Crumbs key={`crumbs-${feedCount}`} />\n      )}\n    </motion.div>\n  );\n}\n\n/* ------------------------------ Component -------------------------------- */\n\ntype Flight = {\n  id: number;\n  from: { x: number; y: number };\n  to: { x: number; y: number };\n};\n\nexport function InteractivePets({\n  pets = DEFAULT_PETS,\n  className,\n  playgroundClassName,\n  showInstructions = true,\n  instructionText = DEFAULT_INSTRUCTIONS,\n  onPetMove,\n  onPetFeed,\n}: InteractivePetsProps) {\n  const reduceMotion = false;\n  const wrapperRef = React.useRef<HTMLDivElement | null>(null);\n  const playgroundRef = React.useRef<HTMLDivElement | null>(null);\n  const petRefs = React.useRef<Partial<Record<PetType, HTMLDivElement | null>>>(\n    {},\n  );\n  const buttonRefs = React.useRef<\n    Partial<Record<PetType, HTMLButtonElement | null>>\n  >({});\n  const bubbleTimers = React.useRef<\n    Partial<Record<PetType, ReturnType<typeof setTimeout>>>\n  >({});\n  const flightId = React.useRef(0);\n\n  const [flights, setFlights] = React.useState<\n    Partial<Record<PetType, Flight>>\n  >({});\n  const [feedCounts, setFeedCounts] = React.useState<\n    Partial<Record<PetType, number>>\n  >({});\n  const feedCountsRef = React.useRef<Partial<Record<PetType, number>>>({});\n  const [reactions, setReactions] = React.useState<\n    Partial<Record<PetType, boolean>>\n  >({});\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const resolvedPets: ResolvedPet[] = pets.map((pet) => ({\n    ...PET_DEFAULTS[pet.id],\n    ...Object.fromEntries(\n      Object.entries(pet).filter(([, value]) => value !== undefined),\n    ),\n    id: pet.id,\n  }));\n\n  React.useEffect(() => {\n    const timers = bubbleTimers.current;\n    return () => Object.values(timers).forEach(clearTimeout);\n  }, []);\n\n  const land = React.useCallback((pet: ResolvedPet) => {\n    const feedCount = (feedCountsRef.current[pet.id] ?? 0) + 1;\n    feedCountsRef.current = { ...feedCountsRef.current, [pet.id]: feedCount };\n    setFeedCounts(feedCountsRef.current);\n    setReactions((prev) => ({ ...prev, [pet.id]: true }));\n    setAnnouncement(\n      `${pet.name} the ${pet.id} was fed. ${getFeedMessage(pet, feedCount)}`,\n    );\n    clearTimeout(bubbleTimers.current[pet.id]);\n    bubbleTimers.current[pet.id] = setTimeout(() => {\n      setReactions((prev) => ({ ...prev, [pet.id]: false }));\n    }, 2400);\n  }, []);\n\n  const feed = (pet: ResolvedPet) => {\n    onPetFeed?.(pet.id);\n    const wrapper = wrapperRef.current;\n    const petEl = petRefs.current[pet.id];\n    const buttonEl = buttonRefs.current[pet.id];\n    if (reduceMotion || !wrapper || !petEl || !buttonEl) {\n      land(pet);\n      return;\n    }\n    const wrapperRect = wrapper.getBoundingClientRect();\n    const petRect = petEl.getBoundingClientRect();\n    const buttonRect = buttonEl.getBoundingClientRect();\n    setFlights((prev) => ({\n      ...prev,\n      [pet.id]: {\n        id: ++flightId.current,\n        from: {\n          x: buttonRect.left + buttonRect.width / 2 - wrapperRect.left,\n          y: buttonRect.top + buttonRect.height / 2 - wrapperRect.top,\n        },\n        to: {\n          x: petRect.left + petRect.width / 2 - wrapperRect.left,\n          y: petRect.top + petRect.height / 2 - wrapperRect.top,\n        },\n      },\n    }));\n  };\n\n  return (\n    <div\n      ref={wrapperRef}\n      className={cn(\"relative w-full select-none\", className)}\n    >\n      <div\n        ref={playgroundRef}\n        className={cn(\n          \"relative h-64 w-full rounded-xl border border-border bg-muted/30 sm:h-80\",\n          playgroundClassName,\n        )}\n      >\n        {resolvedPets.map((pet) => (\n          <PetActor\n            key={pet.id}\n            pet={pet}\n            playgroundRef={playgroundRef}\n            reduceMotion={reduceMotion}\n            feedCount={feedCounts[pet.id] ?? 0}\n            reactionVisible={reactions[pet.id] ?? false}\n            registerRef={(el) => {\n              petRefs.current[pet.id] = el;\n            }}\n            onMove={(position) => onPetMove?.(pet.id, position)}\n          />\n        ))}\n        <div\n          data-food-controls\n          className=\"absolute inset-x-4 bottom-3 flex justify-around\"\n        >\n          {resolvedPets.map((pet) => (\n            <motion.button\n              key={pet.id}\n              type=\"button\"\n              ref={(el) => {\n                buttonRefs.current[pet.id] = el;\n              }}\n              drag\n              dragConstraints={playgroundRef}\n              dragElastic={0}\n              dragMomentum={false}\n              whileDrag={{ scale: 1.1 }}\n              onTap={() => feed(pet)}\n              aria-label={`${pet.name}'s bone. Drag to move or activate to feed ${pet.name} a ${pet.food}`}\n              className=\"pointer-events-auto flex h-6 w-9 cursor-grab items-center justify-center rounded-lg border border-border bg-black text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing\"\n              style={{ touchAction: \"none\" }}\n            >\n              <span aria-hidden>🦴</span>\n            </motion.button>\n          ))}\n        </div>\n      </div>\n\n      {showInstructions && (\n        <p className=\"mt-3 text-center text-xs text-muted-foreground\">\n          {instructionText}\n        </p>\n      )}\n\n      {resolvedPets.map((pet) => {\n        const flight = flights[pet.id];\n        if (!flight) return null;\n        return (\n          <motion.div\n            key={flight.id}\n            initial={{ x: flight.from.x - 10, y: flight.from.y - 10, scale: 1 }}\n            animate={{ x: flight.to.x - 10, y: flight.to.y - 10, scale: 0.5 }}\n            transition={{ duration: 0.45, ease: \"easeInOut\" }}\n            onAnimationComplete={() => {\n              setFlights((prev) => ({ ...prev, [pet.id]: undefined }));\n              land(pet);\n            }}\n            className=\"pointer-events-none absolute left-0 top-0 z-20 text-xl leading-none\"\n          >\n            <span aria-hidden>{FOOD_EMOJIS[pet.id]}</span>\n          </motion.div>\n        );\n      })}\n\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}