{ "$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> & { 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 \n \n \n \n \n \n \n \n );\n}\n\nconst SPRITES: Record> = {\n cat: CatSprite,\n};\n\nconst FOOD_EMOJIS: Record = {\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
\n {CRUMB_OFFSETS.map((offset, i) => (\n \n ))}\n
\n );\n}\n\n/* --------------------------------- Pet ----------------------------------- */\n\ntype ResolvedPet = Required & { 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;\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(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>(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) => {\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 {\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 \n {(reactionVisible || showIdleBubble) && (\n \n {reactionVisible ? getFeedMessage(pet, feedCount) : idleMessage}\n \n )}\n \n \n \n \n {feedCount > 0 && !reduceMotion && (\n \n )}\n \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(null);\n const playgroundRef = React.useRef(null);\n const petRefs = React.useRef>>(\n {},\n );\n const buttonRefs = React.useRef<\n Partial>\n >({});\n const bubbleTimers = React.useRef<\n Partial>>\n >({});\n const flightId = React.useRef(0);\n\n const [flights, setFlights] = React.useState<\n Partial>\n >({});\n const [feedCounts, setFeedCounts] = React.useState<\n Partial>\n >({});\n const feedCountsRef = React.useRef>>({});\n const [reactions, setReactions] = React.useState<\n Partial>\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 \n \n {resolvedPets.map((pet) => (\n {\n petRefs.current[pet.id] = el;\n }}\n onMove={(position) => onPetMove?.(pet.id, position)}\n />\n ))}\n \n {resolvedPets.map((pet) => (\n {\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 🦴\n \n ))}\n \n \n\n {showInstructions && (\n

\n {instructionText}\n

\n )}\n\n {resolvedPets.map((pet) => {\n const flight = flights[pet.id];\n if (!flight) return null;\n return (\n {\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 {FOOD_EMOJIS[pet.id]}\n \n );\n })}\n\n
\n {announcement}\n
\n \n );\n}\n", "type": "registry:component" } ], "type": "registry:component" }