{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "draggable-grid", "title": "Draggable Grid", "description": "A draggable 2D grid with parallax icon cells. Inspired by Apple WatchOS. | 視差付きアイコンセルのドラッグ可能な2Dグリッド。Apple WatchOS にインスパイア。", "dependencies": [ "motion" ], "registryDependencies": [ "tooltip" ], "files": [ { "path": "components/grid/draggable-grid.tsx", "content": "\"use client\";\n\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\nimport { motion, MotionValue, useMotionValue, useTransform } from \"motion/react\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\n/** Minimum item shape for grid cells. (slug: draggable-grid) */\nexport type DraggableGridItemBase = {\n id: string | number;\n href?: string;\n image?: string;\n label?: string;\n};\n\ntype Device = { width: number; height: number };\n\ninterface DraggableGridContextValue {\n device: Device;\n planeX: MotionValue;\n planeY: MotionValue;\n xRange: number[];\n yRange: number[];\n scaleRange: number[];\n translateRange: number[];\n icon: { size: number; margin: number };\n rows: number;\n cols: number;\n mounted: boolean;\n linkComponent?: React.ComponentType<{ href: string; children: ReactNode }>;\n}\n\nconst DraggableGridContext =\n createContext(null);\n\nfunction useDraggableGrid() {\n const ctx = useContext(DraggableGridContext);\n if (!ctx) {\n throw new Error(\n \"DraggableGrid components must be used within DraggableGrid.Root\"\n );\n }\n return ctx;\n}\n\nfunction useWindowSize(): Device {\n const [windowSize, setWindowSize] = useState(() =>\n typeof window !== \"undefined\"\n ? { width: window.innerWidth, height: window.innerHeight }\n : { width: 1920, height: 1080 }\n );\n useEffect(() => {\n function handleResize() {\n setWindowSize({\n width: window.innerWidth,\n height: window.innerHeight,\n });\n }\n window.addEventListener(\"resize\", handleResize);\n handleResize();\n return () => window.removeEventListener(\"resize\", handleResize);\n }, []);\n return windowSize;\n}\n\nconst DEFAULT_ICON = { margin: 40, size: 120 };\n\n/**\n * Root: provides device size, drag plane, and grid config. Wrap Grid and Items.\n * (slug: draggable-grid-root)\n */\nfunction DraggableGridRoot({\n children,\n className,\n iconSize,\n iconMargin,\n rows = 10,\n cols = 10,\n linkComponent,\n fallback,\n}: {\n children: ReactNode;\n className?: string;\n iconSize?: number;\n iconMargin?: number;\n rows?: number;\n cols?: number;\n linkComponent?: React.ComponentType<{\n href: string;\n children: ReactNode;\n }>;\n fallback?: ReactNode;\n}) {\n const [mounted, setMounted] = useState(false);\n const device = useWindowSize();\n useEffect(() => {\n setMounted(true);\n }, []);\n\n const icon = useMemo(\n () => ({\n size: iconSize ?? DEFAULT_ICON.size,\n margin: iconMargin ?? DEFAULT_ICON.margin,\n }),\n [iconSize, iconMargin]\n );\n\n const planeX = useMotionValue(-200);\n const planeY = useMotionValue(-100);\n\n const createScreenRange = useCallback(\n (axis: keyof Device) => [\n -60,\n 80,\n device[axis] - (icon.size + icon.margin) / 2 - 80,\n device[axis] - (icon.size + icon.margin) / 2 + 60,\n ],\n [device, icon.size, icon.margin]\n );\n\n const scaleRange = [0, 1, 1, 0];\n const translateRange = [50, 0, 0, -50];\n const xRange = useMemo(() => createScreenRange(\"width\"), [createScreenRange]);\n const yRange = useMemo(() => createScreenRange(\"height\"), [createScreenRange]);\n\n const value = useMemo(\n () => ({\n device,\n planeX,\n planeY,\n xRange,\n yRange,\n scaleRange,\n translateRange,\n icon,\n rows,\n cols,\n mounted,\n linkComponent,\n }),\n [\n device,\n planeX,\n planeY,\n xRange,\n yRange,\n icon,\n rows,\n cols,\n mounted,\n linkComponent,\n ]\n );\n\n if (!mounted && fallback) {\n return <>{fallback};\n }\n\n if (!mounted) {\n return (\n \n
Loading...
\n \n );\n }\n\n return (\n \n \n {children}\n \n \n );\n}\n\n/**\n * Grid: draggable plane containing items. Use inside Root.\n * (slug: draggable-grid-grid)\n */\nfunction DraggableGridGrid({\n children,\n className,\n dragConstraints = {\n left: -200,\n right: 20,\n top: -500,\n bottom: 50,\n },\n}: {\n children: ReactNode;\n className?: string;\n dragConstraints?: { left: number; right: number; top: number; bottom: number };\n}) {\n const { device, planeX, planeY } = useDraggableGrid();\n return (\n \n {children}\n \n );\n}\n\n/**\n * Item: single cell with parallax. Use inside Grid. Pass item for default link+image+tooltip, or children for custom.\n * (slug: draggable-grid-item)\n */\nfunction DraggableGridItem({\n row,\n col,\n index,\n item,\n linkComponent: itemLinkComponent,\n className,\n children,\n}: {\n row: number;\n col: number;\n index: number;\n item?: T;\n linkComponent?: React.ComponentType<{\n href: string;\n children: ReactNode;\n }>;\n className?: string;\n children?: ReactNode;\n}) {\n const {\n planeX,\n planeY,\n xRange,\n yRange,\n scaleRange,\n translateRange,\n icon,\n linkComponent: rootLinkComponent,\n } = useDraggableGrid();\n\n const xOffset =\n col * (icon.size + icon.margin) +\n (row % 2) * ((icon.size + icon.margin) / 2);\n const yOffset = row * icon.size;\n\n const screenOffsetX = useTransform(() => planeX.get() + xOffset + 20);\n const screenOffsetY = useTransform(() => planeY.get() + yOffset + 20);\n const x = useTransform(screenOffsetX, xRange, translateRange);\n const y = useTransform(screenOffsetY, yRange, translateRange);\n const xScale = useTransform(screenOffsetX, xRange, scaleRange);\n const yScale = useTransform(screenOffsetY, yRange, scaleRange);\n const scale = useTransform(() => Math.min(xScale.get(), yScale.get()));\n\n const defaultClassName =\n \"absolute flex justify-center items-center rounded-full bg-neutral-50 contain-strict overflow-hidden shadow-md hover:shadow-xl hover:scale-105 transition-all\";\n\n if (children !== undefined) {\n return (\n \n {children}\n \n );\n }\n\n const LinkComponent = itemLinkComponent ?? rootLinkComponent ?? \"a\";\n const href = item?.href ?? \"#\";\n const label = item?.label;\n const image = item?.image;\n\n return (\n \n \n \n \n \n \n {image ? (\n // eslint-disable-next-line @next/next/no-img-element\n \n ) : (\n label ?? index\n )}\n \n \n \n {label != null && (\n \n

{label}

\n
\n )}\n
\n
\n \n );\n}\n\nexport const DraggableGrid = {\n Root: DraggableGridRoot,\n Grid: DraggableGridGrid,\n Item: DraggableGridItem,\n};\n\nexport { useDraggableGrid };\n", "type": "registry:ui" } ], "type": "registry:ui" }