{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["cards-grids","geometric"],"dependencies":["motion"],"description":"Infinite pannable grid — drag or scroll with momentum through endless cells, indexed in a spiral out from the center and rendered only while in view.","devDependencies":[],"files":[{"content":"import { useEffect, useRef, useState } from \"react\";\nimport type {\n  Dispatch,\n  MouseEvent as ReactMouseEvent,\n  ReactNode,\n  SetStateAction,\n  TouchEvent as ReactTouchEvent,\n} from \"react\";\n// Grid physics constants\nconst MIN_VELOCITY = 0.2;\nconst UPDATE_INTERVAL = 16;\nconst VELOCITY_HISTORY_SIZE = 5;\nconst FRICTION = 0.9;\nconst VELOCITY_THRESHOLD = 0.3;\n// Distance (px) the grid must sit away from its rest position to count as \"moving\".\nconst MOVING_DISTANCE = 5;\n// Idle time (ms) after the last update before the grid settles back to rest.\nconst REST_DELAY = 200;\n\n// Custom debounce implementation\nconst debounce = (func: () => void, wait: number) => {\n  let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n  const debouncedFn = () => {\n    clearTimeout(timeoutId);\n    timeoutId = setTimeout(() => {\n      func();\n      timeoutId = undefined;\n    }, wait);\n  };\n\n  debouncedFn.cancel = () => {\n    clearTimeout(timeoutId);\n    timeoutId = undefined;\n  };\n\n  return debouncedFn;\n};\n\n// Custom throttle implementation (leading + trailing)\nconst throttle = (func: () => void, limit: number) => {\n  let lastCall = 0;\n  let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n  const throttledFn = () => {\n    const now = Date.now();\n    const remaining = limit - (now - lastCall);\n\n    if (remaining <= 0 || remaining > limit) {\n      clearTimeout(timeoutId);\n      timeoutId = undefined;\n      lastCall = now;\n      func();\n    } else if (!timeoutId) {\n      timeoutId = setTimeout(() => {\n        lastCall = Date.now();\n        timeoutId = undefined;\n        func();\n      }, remaining);\n    }\n  };\n\n  throttledFn.cancel = () => {\n    if (timeoutId) {\n      clearTimeout(timeoutId);\n      timeoutId = undefined;\n    }\n  };\n\n  return throttledFn;\n};\n\ninterface Position {\n  x: number;\n  y: number;\n}\n\ninterface Size {\n  width: number;\n  height: number;\n}\n\ninterface GridItem {\n  position: Position;\n  gridIndex: number;\n}\n\nexport interface GridItemConfig {\n  isMoving: boolean;\n  position: Position;\n  gridIndex: number;\n}\n\nexport interface InfiniteGridProps {\n  gridSize: number;\n  renderItem: (itemConfig: GridItemConfig) => ReactNode;\n  className?: string;\n  initialPosition?: Position;\n}\n\nconst ORIGIN: Position = { x: 0, y: 0 };\nconst NO_SIZE: Size = { height: 0, width: 0 };\n\nconst getDistance = (p1: Position, p2: Position) => Math.hypot(p2.x - p1.x, p2.y - p1.y);\n\nconst getItemIndexForPosition = (x: number, y: number): number => {\n  // Special case for center\n  if (x === 0 && y === 0) {\n    return 0;\n  }\n\n  // Determine which layer of the spiral we're in\n  const layer = Math.max(Math.abs(x), Math.abs(y));\n\n  // Calculate the size of all inner layers\n  const innerLayersSize = (2 * layer - 1) ** 2;\n\n  // Calculate position within current layer\n  let positionInLayer = 0;\n\n  if (y === 0 && x === layer) {\n    // Starting position (middle right)\n    positionInLayer = 0;\n  } else if (y < 0 && x === layer) {\n    // Right side, bottom half\n    positionInLayer = -y;\n  } else if (y === -layer && x > -layer) {\n    // Bottom side\n    positionInLayer = layer + (layer - x);\n  } else if (x === -layer && y < layer) {\n    // Left side\n    positionInLayer = 3 * layer + (layer + y);\n  } else if (y === layer && x < layer) {\n    // Top side\n    positionInLayer = 5 * layer + (layer + x);\n  } else {\n    // Right side, top half (y > 0 && x === layer)\n    positionInLayer = 7 * layer + (layer - y);\n  }\n\n  return innerLayersSize + positionInLayer;\n};\n\nconst calculateVisiblePositions = (rect: Size, gridSize: number, offset: Position): Position[] => {\n  // Calculate grid cells needed to fill container\n  const cellsX = Math.ceil(rect.width / gridSize);\n  const cellsY = Math.ceil(rect.height / gridSize);\n\n  // Calculate center position based on offset\n  const centerX = -Math.round(offset.x / gridSize);\n  const centerY = -Math.round(offset.y / gridSize);\n\n  const positions: Position[] = [];\n  const halfCellsX = Math.ceil(cellsX / 2);\n  const halfCellsY = Math.ceil(cellsY / 2);\n\n  for (let y = centerY - halfCellsY; y <= centerY + halfCellsY; y += 1) {\n    for (let x = centerX - halfCellsX; x <= centerX + halfCellsX; x += 1) {\n      positions.push({ x, y });\n    }\n  }\n\n  return positions;\n};\n\n/** What the grid renders. The engine writes here; nothing else does. */\ninterface View {\n  setContainerSize: Dispatch<SetStateAction<Size>>;\n  setGridItems: Dispatch<SetStateAction<GridItem[]>>;\n  setIsDragging: Dispatch<SetStateAction<boolean>>;\n  setIsMoving: Dispatch<SetStateAction<boolean>>;\n  setOffset: Dispatch<SetStateAction<Position>>;\n}\n\n/**\n * The pan physics, integrated outside React: the loop, the throttle and the\n * gesture scratch all live here, and only what the picture needs is mirrored\n * into state through `view`.\n */\nconst createEngine = (initialOffset: Position, initialGridSize: number, view: View) => {\n  let container: HTMLDivElement | null = null;\n  let gridSize = initialGridSize;\n  let offset = { ...initialOffset };\n  let restPos = { ...initialOffset };\n  let startPos = { ...initialOffset };\n  let velocity: Position = { x: 0, y: 0 };\n  let isDragging = false;\n  let mounted = false;\n  let lastPos: Position = { x: 0, y: 0 };\n  let lastMoveTime = 0;\n  let lastUpdateTime = 0;\n  let animationFrame: number | null = null;\n  const velocityHistory: Position[] = [];\n\n  const stopMoving = debounce(() => {\n    restPos = { ...offset };\n    view.setIsMoving(false);\n  }, REST_DELAY);\n\n  const updateGridItems = () => {\n    if (!mounted) {\n      return;\n    }\n\n    const rect = container?.getBoundingClientRect();\n    const size: Size = rect ? { height: rect.height, width: rect.width } : NO_SIZE;\n    const positions = rect ? calculateVisiblePositions(rect, gridSize, offset) : [];\n    const newItems = positions.map((position) => ({\n      gridIndex: getItemIndexForPosition(position.x, position.y),\n      position,\n    }));\n\n    view.setContainerSize(size);\n    view.setGridItems(newItems);\n    view.setIsMoving(getDistance(offset, restPos) > MOVING_DISTANCE);\n\n    stopMoving();\n  };\n\n  const throttledUpdateGridItems = throttle(updateGridItems, UPDATE_INTERVAL);\n\n  const animate = () => {\n    if (!mounted) {\n      return;\n    }\n\n    const currentTime = performance.now();\n    const deltaTime = currentTime - lastUpdateTime;\n\n    if (deltaTime >= UPDATE_INTERVAL) {\n      const speed = Math.hypot(velocity.x, velocity.y);\n\n      if (speed < MIN_VELOCITY) {\n        animationFrame = null;\n        velocity = { x: 0, y: 0 };\n        return;\n      }\n\n      // Apply non-linear deceleration based on speed\n      let deceleration = FRICTION;\n      if (speed < VELOCITY_THRESHOLD) {\n        // Apply stronger deceleration at lower speeds for more natural stopping\n        deceleration = FRICTION * (speed / VELOCITY_THRESHOLD);\n      }\n\n      offset = { x: offset.x + velocity.x, y: offset.y + velocity.y };\n      velocity = { x: velocity.x * deceleration, y: velocity.y * deceleration };\n      view.setOffset(offset);\n      throttledUpdateGridItems();\n\n      lastUpdateTime = currentTime;\n    }\n\n    animationFrame = requestAnimationFrame(animate);\n  };\n\n  const handleDown = (p: Position) => {\n    if (animationFrame) {\n      cancelAnimationFrame(animationFrame);\n      animationFrame = null;\n    }\n\n    isDragging = true;\n    startPos = { x: p.x - offset.x, y: p.y - offset.y };\n    velocity = { x: 0, y: 0 };\n    view.setIsDragging(true);\n\n    lastPos = { x: p.x, y: p.y };\n  };\n\n  const handleMove = (p: Position) => {\n    if (!isDragging) {\n      return;\n    }\n\n    const currentTime = performance.now();\n    const timeDelta = currentTime - lastMoveTime;\n\n    // Calculate raw velocity based on position and time\n    const rawVelocity = {\n      x: (p.x - lastPos.x) / (timeDelta || 1),\n      y: (p.y - lastPos.y) / (timeDelta || 1),\n    };\n\n    // Add to velocity history and maintain fixed size\n    velocityHistory.push(rawVelocity);\n    if (velocityHistory.length > VELOCITY_HISTORY_SIZE) {\n      velocityHistory.shift();\n    }\n\n    // Calculate smoothed velocity using moving average\n    const smoothedVelocity = { x: 0, y: 0 };\n    for (const vel of velocityHistory) {\n      smoothedVelocity.x += vel.x / velocityHistory.length;\n      smoothedVelocity.y += vel.y / velocityHistory.length;\n    }\n\n    offset = { x: p.x - startPos.x, y: p.y - startPos.y };\n    velocity = smoothedVelocity;\n    view.setOffset(offset);\n    updateGridItems();\n\n    lastMoveTime = currentTime;\n    lastPos = { x: p.x, y: p.y };\n  };\n\n  const handleUp = () => {\n    // Also fires on mouseleave/touchcancel, which can arrive without a drag in\n    // flight — bail out so we don't spawn a second animation loop.\n    if (!isDragging) {\n      return;\n    }\n\n    isDragging = false;\n    view.setIsDragging(false);\n\n    if (animationFrame) {\n      cancelAnimationFrame(animationFrame);\n    }\n    animationFrame = requestAnimationFrame(animate);\n  };\n\n  const handleTouchMove = (e: TouchEvent) => {\n    const touch = e.touches.item(0);\n\n    if (!touch) {\n      return;\n    }\n\n    e.preventDefault();\n    handleMove({ x: touch.clientX, y: touch.clientY });\n  };\n\n  const handleWheel = (e: WheelEvent) => {\n    e.preventDefault();\n\n    offset = { x: offset.x - e.deltaX, y: offset.y - e.deltaY };\n    velocity = { x: 0, y: 0 };\n    view.setOffset(offset);\n    throttledUpdateGridItems();\n  };\n\n  const mount = (element: HTMLDivElement | null) => {\n    container = element;\n    mounted = true;\n    updateGridItems();\n\n    // Non-passive, so the handlers can cancel the page's own scrolling.\n    element?.addEventListener(\"wheel\", handleWheel, { passive: false });\n    element?.addEventListener(\"touchmove\", handleTouchMove, { passive: false });\n\n    return () => {\n      mounted = false;\n      container = null;\n      if (animationFrame) {\n        cancelAnimationFrame(animationFrame);\n      }\n      throttledUpdateGridItems.cancel();\n      stopMoving.cancel();\n\n      element?.removeEventListener(\"wheel\", handleWheel);\n      element?.removeEventListener(\"touchmove\", handleTouchMove);\n    };\n  };\n\n  const setGridSize = (size: number) => {\n    gridSize = size;\n  };\n\n  return { handleDown, handleMove, handleUp, mount, setGridSize };\n};\n\ntype Engine = ReturnType<typeof createEngine>;\n\nexport const InfiniteGrid = ({\n  gridSize,\n  renderItem,\n  className,\n  initialPosition,\n}: InfiniteGridProps) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [offset, setOffset] = useState<Position>(() => ({ ...(initialPosition ?? ORIGIN) }));\n  const [isDragging, setIsDragging] = useState(false);\n  const [isMoving, setIsMoving] = useState(false);\n  const [gridItems, setGridItems] = useState<GridItem[]>([]);\n  const [containerSize, setContainerSize] = useState<Size>(NO_SIZE);\n\n  const engineRef = useRef<Engine | null>(null);\n  if (engineRef.current === null) {\n    engineRef.current = createEngine(initialPosition ?? ORIGIN, gridSize, {\n      setContainerSize,\n      setGridItems,\n      setIsDragging,\n      setIsMoving,\n      setOffset,\n    });\n  }\n\n  useEffect(() => {\n    engineRef.current?.setGridSize(gridSize);\n  }, [gridSize]);\n\n  useEffect(() => engineRef.current?.mount(containerRef.current), []);\n\n  const handleMouseDown = (e: ReactMouseEvent) => {\n    engineRef.current?.handleDown({ x: e.clientX, y: e.clientY });\n  };\n\n  const handleMouseMove = (e: ReactMouseEvent) => {\n    e.preventDefault();\n    engineRef.current?.handleMove({ x: e.clientX, y: e.clientY });\n  };\n\n  const handleTouchStart = (e: ReactTouchEvent) => {\n    const touch = e.touches.item(0);\n\n    if (!touch) {\n      return;\n    }\n\n    engineRef.current?.handleDown({ x: touch.clientX, y: touch.clientY });\n  };\n\n  const handleUp = () => {\n    engineRef.current?.handleUp();\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      role=\"presentation\"\n      className={className}\n      style={{\n        cursor: isDragging ? \"grabbing\" : \"grab\",\n        inset: 0,\n        overflow: \"hidden\",\n        position: \"absolute\",\n        touchAction: \"none\",\n      }}\n      onMouseDown={handleMouseDown}\n      onMouseMove={handleMouseMove}\n      onMouseUp={handleUp}\n      onMouseLeave={handleUp}\n      onTouchStart={handleTouchStart}\n      onTouchEnd={handleUp}\n      onTouchCancel={handleUp}\n    >\n      <div\n        style={{\n          inset: 0,\n          position: \"absolute\",\n          transform: `translate3d(${offset.x}px, ${offset.y}px, 0)`,\n          willChange: \"transform\",\n        }}\n      >\n        {gridItems.map((item) => {\n          const x = item.position.x * gridSize + containerSize.width / 2;\n          const y = item.position.y * gridSize + containerSize.height / 2;\n\n          return (\n            <div\n              key={`${item.position.x}-${item.position.y}`}\n              style={{\n                alignItems: \"center\",\n                display: \"flex\",\n                height: gridSize,\n                justifyContent: \"center\",\n                marginLeft: `-${gridSize / 2}px`,\n                marginTop: `-${gridSize / 2}px`,\n                position: \"absolute\",\n                transform: `translate3d(${x}px, ${y}px, 0)`,\n                userSelect: \"none\",\n                width: gridSize,\n                willChange: \"transform\",\n              }}\n            >\n              {renderItem({\n                gridIndex: item.gridIndex,\n                isMoving,\n                position: item.position,\n              })}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n","path":"/infinite-grid.tsx","target":"uicapsule/infinite-grid/infinite-grid.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { motion } from \"motion/react\";\n\nimport type { GridItemConfig } from \"./infinite-grid\";\nimport { InfiniteGrid } from \"./infinite-grid\";\n\nconst Cell = ({ gridIndex }: GridItemConfig) => (\n  <motion.div\n    className=\"absolute inset-1 flex items-center justify-center\"\n    initial={{ opacity: 0, scale: 0 }}\n    animate={{ opacity: 1, scale: 1 }}\n    transition={{\n      duration: 0.4,\n      scale: { bounce: 0.5, type: \"spring\", visualDuration: 0.4 },\n    }}\n  >\n    <img\n      alt=\"\"\n      className=\"pointer-events-none size-20\"\n      src={`https://d24l2zb4cwkekfpl.public.blob.vercel-storage.com/illustrations/blueprint/%20${(gridIndex % 100) + 1}.svg`}\n    />\n  </motion.div>\n);\n\nconst Preview = () => (\n  <div className=\"h-screen w-screen bg-blue-50\">\n    <InfiniteGrid gridSize={150} renderItem={Cell} />\n  </div>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/infinite-grid/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/infinite-grid","name":"infinite-grid","registryDependencies":[],"title":"Infinite Grid","type":"registry:block"}