{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "motion-wheel", "title": "Motion Wheel", "description": "A compound carousel wheel with rotation animation, navigation, and optional center info. | 回転アニメーション、ナビゲーション、オプションの中央情報を備えた複合カルーセルホイールコンポーネント。", "dependencies": [ "motion", "lucide-react" ], "registryDependencies": [ "card", "button", "use-mobile" ], "files": [ { "path": "components/carousel/motion-wheel.tsx", "content": "\"use client\";\n\nimport React, {\n createContext,\n useState,\n useContext,\n useCallback,\n useMemo,\n useRef,\n useEffect,\n type ReactNode,\n} from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { cn } from \"@/lib/utils\";\n\n/** Minimum item shape: must have an id for keys and positioning. (slug: motion-wheel) */\nexport type MotionWheelItemBase = { id: string | number };\n\n/** Context value for wheel state, actions, and config. Injected by MotionWheel.Root. */\ninterface MotionWheelContextValue {\n state: {\n items: T[];\n currentIndex: number;\n wheelRotation: number;\n isAnimating: boolean;\n };\n actions: {\n goNext: () => void;\n goPrev: () => void;\n goToIndex: (index: number) => void;\n };\n meta: {\n radius: number;\n count: number;\n spring: { stiffness: number; damping: number; duration: number };\n };\n}\n\nconst MotionWheelContext =\n createContext | null>(null);\n\n/**\n * Use: Access wheel state and actions. Must be used within MotionWheel.Root.\n * Returns { state, actions, meta } for the current wheel.\n * (slug: motion-wheel-hook)\n */\nexport function useMotionWheel() {\n const ctx = useContext(\n MotionWheelContext,\n ) as MotionWheelContextValue | null;\n if (!ctx) {\n throw new Error(\"useMotionWheel must be used within MotionWheel.Root\");\n }\n return ctx;\n}\n\n/**\n * Use: Wrap the entire wheel. Pass `items` array and optional radius/spring/className.\n * Must wrap all other MotionWheel parts. Place as the outermost wrapper.\n * Props: items (required), radius?, spring?, className?, initialIndex?\n * (slug: motion-wheel-root)\n */\nfunction MotionWheelRoot({\n items,\n children,\n className,\n radius: radiusProp,\n spring = { stiffness: 120, damping: 25, duration: 0.5 },\n initialIndex = 0,\n}: {\n items: T[];\n children: ReactNode;\n className?: string;\n radius?: number;\n spring?: { stiffness?: number; damping?: number; duration?: number };\n initialIndex?: number;\n}) {\n const isMobile = useIsMobile();\n const radius = radiusProp ?? (isMobile ? 270 : 320);\n\n const [currentIndex, setCurrentIndex] = useState(initialIndex);\n const [isAnimating, setIsAnimating] = useState(false);\n const [wheelRotation, setWheelRotation] = useState(0);\n\n const count = items.length;\n const angleStep = count > 0 ? 360 / count : 0;\n\n const goNext = useCallback(() => {\n if (isAnimating || count === 0) return;\n setIsAnimating(true);\n setWheelRotation((prev) => prev + angleStep);\n setCurrentIndex((prev) => (prev - 1 + count) % count);\n setTimeout(() => setIsAnimating(false), 500);\n }, [angleStep, count, isAnimating]);\n\n const goPrev = useCallback(() => {\n if (isAnimating || count === 0) return;\n setIsAnimating(true);\n setWheelRotation((prev) => prev - angleStep);\n setCurrentIndex((prev) => (prev + 1) % count);\n setTimeout(() => setIsAnimating(false), 500);\n }, [angleStep, count, isAnimating]);\n\n const goToIndex = useCallback(\n (index: number) => {\n if (isAnimating || count === 0) return;\n setIsAnimating(true);\n const rotationDiff = (currentIndex - index) * angleStep;\n setWheelRotation((prev) => prev + rotationDiff);\n setCurrentIndex(index);\n setTimeout(() => setIsAnimating(false), 500);\n },\n [angleStep, count, currentIndex, isAnimating],\n );\n\n const value = useMemo(\n (): MotionWheelContextValue => ({\n state: {\n items,\n currentIndex,\n wheelRotation,\n isAnimating,\n },\n actions: { goNext, goPrev, goToIndex },\n meta: {\n radius,\n count,\n spring: {\n stiffness: spring.stiffness ?? 120,\n damping: spring.damping ?? 25,\n duration: spring.duration ?? 0.5,\n },\n },\n }),\n [\n items,\n currentIndex,\n wheelRotation,\n isAnimating,\n goNext,\n goPrev,\n goToIndex,\n radius,\n count,\n spring,\n ],\n );\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (count === 0 || isAnimating) return;\n if (e.key === \"ArrowLeft\") {\n e.preventDefault();\n goPrev();\n } else if (e.key === \"ArrowRight\") {\n e.preventDefault();\n goNext();\n }\n },\n [count, isAnimating, goPrev, goNext],\n );\n\n return (\n }\n >\n \n {children}\n \n \n );\n}\n\n/**\n * Use: Optional decorative border circle. Place inside Root, typically behind the wheel.\n * Props: className?\n * (slug: motion-wheel-border)\n */\nfunction MotionWheelBorder({ className }: { className?: string }) {\n return (\n \n );\n}\n\n/**\n * Use: Opt-in dynamic carousel. Place inside Root to auto-advance to the next item.\n * When absent, the wheel is manual-only. Props: interval? (ms per item, default 3000).\n * (slug: motion-wheel-auto-carousel)\n */\nfunction MotionWheelAutoCarousel({\n interval = 3000,\n}: {\n interval?: number;\n}) {\n const { actions, meta } = useMotionWheel();\n\n useEffect(() => {\n if (meta.count === 0) return;\n const id = setInterval(() => actions.goNext(), interval);\n return () => clearInterval(id);\n }, [interval, actions.goNext, meta.count]);\n\n return null;\n}\n\n/**\n * Use: The rotating wheel container. Renders each item via children render function.\n * Place inside Root. Props: children(item, index) => ReactNode.\n * (slug: motion-wheel-wheel)\n */\nfunction MotionWheelWheel({\n children,\n}: {\n children: (item: T, index: number) => ReactNode;\n}) {\n const { state, meta } = useMotionWheel();\n\n return (\n \n \n {state.items.map((item, index) => (\n {children(item, index)}\n ))}\n \n \n );\n}\n\n/**\n * Use: Wraps a single carousel item. Place inside MotionWheel.Wheel's render function.\n * Handles position, scale, zIndex. Pass card content as children.\n * Props: item (required), index (required), children (required).\n * (slug: motion-wheel-item)\n */\nfunction MotionWheelItem({\n item,\n index,\n children,\n}: {\n item: T;\n index: number;\n children: ReactNode;\n}) {\n const { state, meta } = useMotionWheel();\n const prevRotationRef = useRef(0);\n const isCurrent = state.items[state.currentIndex]?.id === item.id;\n const position = index - state.currentIndex;\n const angle = position * (360 / meta.count);\n const x = isCurrent ? 0 : Math.sin((angle * Math.PI) / 180) * meta.radius;\n const y = isCurrent ? 0 : -Math.cos((angle * Math.PI) / 180) * meta.radius;\n const scale = isCurrent ? 1 : 0.4;\n const zIndex = isCurrent ? 50 : 5 - Math.abs(position);\n\n const rawTarget = isCurrent ? -state.wheelRotation : angle;\n const prev = prevRotationRef.current;\n const cardRotation = isCurrent\n ? // Current item: counteract parent wheel rotation so it stays upright (0°)\n (() => {\n const n = Math.round((prev - rawTarget) / 360);\n return rawTarget + n * 360;\n })()\n : // Other items: maintain their original angle\n angle;\n prevRotationRef.current = cardRotation;\n\n return (\n \n {children}\n \n );\n}\n\n/**\n * Use: Prev/Next navigation buttons. Place inside Root, typically at wheel sides.\n * Props: className?, prevClassName?, nextClassName?\n * (slug: motion-wheel-navigation)\n */\nfunction MotionWheelNavigation({\n className,\n prevClassName,\n nextClassName,\n}: {\n className?: string;\n prevClassName?: string;\n nextClassName?: string;\n}) {\n const { state, actions } = useMotionWheel();\n\n return (\n \n \n \n \n \n \n \n \n );\n}\n\n/**\n * Use: Dot indicators for direct index navigation. Place inside Root, typically at bottom.\n * Props: className?\n * (slug: motion-wheel-dots)\n */\nfunction MotionWheelDots({ className }: { className?: string }) {\n const { state, actions } = useMotionWheel();\n const currentItem = state.items[state.currentIndex];\n\n return (\n \n {state.items.map((item, index) => (\n actions.goToIndex(index)}\n className={cn(\n \"size-3 rounded-full transition-all duration-300 cursor-pointer\",\n currentItem?.id === item.id\n ? \"bg-accent scale-125\"\n : \"bg-white/50 hover:bg-primary\",\n )}\n />\n ))}\n \n );\n}\n\n/**\n * Use: Bottom panel showing current item info. Place inside Root.\n * Pass children as function (item) => ReactNode to customize, or use default slot.\n * Props: children?: (item) => ReactNode, className?\n * (slug: motion-wheel-center-info)\n */\nfunction MotionWheelCenterInfo({\n children,\n className,\n}: {\n children?: (item: T) => ReactNode;\n className?: string;\n}) {\n const { state } = useMotionWheel();\n const currentItem = state.items[state.currentIndex];\n\n if (!currentItem) return null;\n\n return (\n \n {children ? children(currentItem) : null}\n \n );\n}\n\nconst MotionWheel = {\n Root: MotionWheelRoot,\n Border: MotionWheelBorder,\n AutoCarousel: MotionWheelAutoCarousel,\n Wheel: MotionWheelWheel,\n Item: MotionWheelItem,\n Navigation: MotionWheelNavigation,\n Dots: MotionWheelDots,\n CenterInfo: MotionWheelCenterInfo,\n};\n\nexport { MotionWheel };\n", "type": "registry:ui" } ], "type": "registry:ui" }