# Infinite Grid

> 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.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/infinite-grid).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/infinite-grid
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/infinite-grid-light-fdd636c46db831f1.png
- Registry item: https://uicapsule.com/r/infinite-grid.json
- Categories: cards-grids, geometric
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/infinite-grid.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`

## Files

### `/infinite-grid.tsx`

```tsx
import { useEffect, useRef, useState } from "react";
import type {
  Dispatch,
  MouseEvent as ReactMouseEvent,
  ReactNode,
  SetStateAction,
  TouchEvent as ReactTouchEvent,
} from "react";
// Grid physics constants
const MIN_VELOCITY = 0.2;
const UPDATE_INTERVAL = 16;
const VELOCITY_HISTORY_SIZE = 5;
const FRICTION = 0.9;
const VELOCITY_THRESHOLD = 0.3;
// Distance (px) the grid must sit away from its rest position to count as "moving".
const MOVING_DISTANCE = 5;
// Idle time (ms) after the last update before the grid settles back to rest.
const REST_DELAY = 200;

// Custom debounce implementation
const debounce = (func: () => void, wait: number) => {
  let timeoutId: ReturnType<typeof setTimeout> | undefined;

  const debouncedFn = () => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      func();
      timeoutId = undefined;
    }, wait);
  };

  debouncedFn.cancel = () => {
    clearTimeout(timeoutId);
    timeoutId = undefined;
  };

  return debouncedFn;
};

// Custom throttle implementation (leading + trailing)
const throttle = (func: () => void, limit: number) => {
  let lastCall = 0;
  let timeoutId: ReturnType<typeof setTimeout> | undefined;

  const throttledFn = () => {
    const now = Date.now();
    const remaining = limit - (now - lastCall);

    if (remaining <= 0 || remaining > limit) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
      lastCall = now;
      func();
    } else if (!timeoutId) {
      timeoutId = setTimeout(() => {
        lastCall = Date.now();
        timeoutId = undefined;
        func();
      }, remaining);
    }
  };

  throttledFn.cancel = () => {
    if (timeoutId) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
    }
  };

  return throttledFn;
};

interface Position {
  x: number;
  y: number;
}

interface Size {
  width: number;
  height: number;
}

interface GridItem {
  position: Position;
  gridIndex: number;
}

export interface GridItemConfig {
  isMoving: boolean;
  position: Position;
  gridIndex: number;
}

export interface InfiniteGridProps {
  gridSize: number;
  renderItem: (itemConfig: GridItemConfig) => ReactNode;
  className?: string;
  initialPosition?: Position;
}

const ORIGIN: Position = { x: 0, y: 0 };
const NO_SIZE: Size = { height: 0, width: 0 };

const getDistance = (p1: Position, p2: Position) => Math.hypot(p2.x - p1.x, p2.y - p1.y);

const getItemIndexForPosition = (x: number, y: number): number => {
  // Special case for center
  if (x === 0 && y === 0) {
    return 0;
  }

  // Determine which layer of the spiral we're in
  const layer = Math.max(Math.abs(x), Math.abs(y));

  // Calculate the size of all inner layers
  const innerLayersSize = (2 * layer - 1) ** 2;

  // Calculate position within current layer
  let positionInLayer = 0;

  if (y === 0 && x === layer) {
    // Starting position (middle right)
    positionInLayer = 0;
  } else if (y < 0 && x === layer) {
    // Right side, bottom half
    positionInLayer = -y;
  } else if (y === -layer && x > -layer) {
    // Bottom side
    positionInLayer = layer + (layer - x);
  } else if (x === -layer && y < layer) {
    // Left side
    positionInLayer = 3 * layer + (layer + y);
  } else if (y === layer && x < layer) {
    // Top side
    positionInLayer = 5 * layer + (layer + x);
  } else {
    // Right side, top half (y > 0 && x === layer)
    positionInLayer = 7 * layer + (layer - y);
  }

  return innerLayersSize + positionInLayer;
};

const calculateVisiblePositions = (rect: Size, gridSize: number, offset: Position): Position[] => {
  // Calculate grid cells needed to fill container
  const cellsX = Math.ceil(rect.width / gridSize);
  const cellsY = Math.ceil(rect.height / gridSize);

  // Calculate center position based on offset
  const centerX = -Math.round(offset.x / gridSize);
  const centerY = -Math.round(offset.y / gridSize);

  const positions: Position[] = [];
  const halfCellsX = Math.ceil(cellsX / 2);
  const halfCellsY = Math.ceil(cellsY / 2);

  for (let y = centerY - halfCellsY; y <= centerY + halfCellsY; y += 1) {
    for (let x = centerX - halfCellsX; x <= centerX + halfCellsX; x += 1) {
      positions.push({ x, y });
    }
  }

  return positions;
};

/** What the grid renders. The engine writes here; nothing else does. */
interface View {
  setContainerSize: Dispatch<SetStateAction<Size>>;
  setGridItems: Dispatch<SetStateAction<GridItem[]>>;
  setIsDragging: Dispatch<SetStateAction<boolean>>;
  setIsMoving: Dispatch<SetStateAction<boolean>>;
  setOffset: Dispatch<SetStateAction<Position>>;
}

/**
 * The pan physics, integrated outside React: the loop, the throttle and the
 * gesture scratch all live here, and only what the picture needs is mirrored
 * into state through `view`.
 */
const createEngine = (initialOffset: Position, initialGridSize: number, view: View) => {
  let container: HTMLDivElement | null = null;
  let gridSize = initialGridSize;
  let offset = { ...initialOffset };
  let restPos = { ...initialOffset };
  let startPos = { ...initialOffset };
  let velocity: Position = { x: 0, y: 0 };
  let isDragging = false;
  let mounted = false;
  let lastPos: Position = { x: 0, y: 0 };
  let lastMoveTime = 0;
  let lastUpdateTime = 0;
  let animationFrame: number | null = null;
  const velocityHistory: Position[] = [];

  const stopMoving = debounce(() => {
    restPos = { ...offset };
    view.setIsMoving(false);
  }, REST_DELAY);

  const updateGridItems = () => {
    if (!mounted) {
      return;
    }

    const rect = container?.getBoundingClientRect();
    const size: Size = rect ? { height: rect.height, width: rect.width } : NO_SIZE;
    const positions = rect ? calculateVisiblePositions(rect, gridSize, offset) : [];
    const newItems = positions.map((position) => ({
      gridIndex: getItemIndexForPosition(position.x, position.y),
      position,
    }));

    view.setContainerSize(size);
    view.setGridItems(newItems);
    view.setIsMoving(getDistance(offset, restPos) > MOVING_DISTANCE);

    stopMoving();
  };

  const throttledUpdateGridItems = throttle(updateGridItems, UPDATE_INTERVAL);

  const animate = () => {
    if (!mounted) {
      return;
    }

    const currentTime = performance.now();
    const deltaTime = currentTime - lastUpdateTime;

    if (deltaTime >= UPDATE_INTERVAL) {
      const speed = Math.hypot(velocity.x, velocity.y);

      if (speed < MIN_VELOCITY) {
        animationFrame = null;
        velocity = { x: 0, y: 0 };
        return;
      }

      // Apply non-linear deceleration based on speed
      let deceleration = FRICTION;
      if (speed < VELOCITY_THRESHOLD) {
        // Apply stronger deceleration at lower speeds for more natural stopping
        deceleration = FRICTION * (speed / VELOCITY_THRESHOLD);
      }

      offset = { x: offset.x + velocity.x, y: offset.y + velocity.y };
      velocity = { x: velocity.x * deceleration, y: velocity.y * deceleration };
      view.setOffset(offset);
      throttledUpdateGridItems();

      lastUpdateTime = currentTime;
    }

    animationFrame = requestAnimationFrame(animate);
  };

  const handleDown = (p: Position) => {
    if (animationFrame) {
      cancelAnimationFrame(animationFrame);
      animationFrame = null;
    }

    isDragging = true;
    startPos = { x: p.x - offset.x, y: p.y - offset.y };
    velocity = { x: 0, y: 0 };
    view.setIsDragging(true);

    lastPos = { x: p.x, y: p.y };
  };

  const handleMove = (p: Position) => {
    if (!isDragging) {
      return;
    }

    const currentTime = performance.now();
    const timeDelta = currentTime - lastMoveTime;

    // Calculate raw velocity based on position and time
    const rawVelocity = {
      x: (p.x - lastPos.x) / (timeDelta || 1),
      y: (p.y - lastPos.y) / (timeDelta || 1),
    };

    // Add to velocity history and maintain fixed size
    velocityHistory.push(rawVelocity);
    if (velocityHistory.length > VELOCITY_HISTORY_SIZE) {
      velocityHistory.shift();
    }

    // Calculate smoothed velocity using moving average
    const smoothedVelocity = { x: 0, y: 0 };
    for (const vel of velocityHistory) {
      smoothedVelocity.x += vel.x / velocityHistory.length;
      smoothedVelocity.y += vel.y / velocityHistory.length;
    }

    offset = { x: p.x - startPos.x, y: p.y - startPos.y };
    velocity = smoothedVelocity;
    view.setOffset(offset);
    updateGridItems();

    lastMoveTime = currentTime;
    lastPos = { x: p.x, y: p.y };
  };

  const handleUp = () => {
    // Also fires on mouseleave/touchcancel, which can arrive without a drag in
    // flight — bail out so we don't spawn a second animation loop.
    if (!isDragging) {
      return;
    }

    isDragging = false;
    view.setIsDragging(false);

    if (animationFrame) {
      cancelAnimationFrame(animationFrame);
    }
    animationFrame = requestAnimationFrame(animate);
  };

  const handleTouchMove = (e: TouchEvent) => {
    const touch = e.touches.item(0);

    if (!touch) {
      return;
    }

    e.preventDefault();
    handleMove({ x: touch.clientX, y: touch.clientY });
  };

  const handleWheel = (e: WheelEvent) => {
    e.preventDefault();

    offset = { x: offset.x - e.deltaX, y: offset.y - e.deltaY };
    velocity = { x: 0, y: 0 };
    view.setOffset(offset);
    throttledUpdateGridItems();
  };

  const mount = (element: HTMLDivElement | null) => {
    container = element;
    mounted = true;
    updateGridItems();

    // Non-passive, so the handlers can cancel the page's own scrolling.
    element?.addEventListener("wheel", handleWheel, { passive: false });
    element?.addEventListener("touchmove", handleTouchMove, { passive: false });

    return () => {
      mounted = false;
      container = null;
      if (animationFrame) {
        cancelAnimationFrame(animationFrame);
      }
      throttledUpdateGridItems.cancel();
      stopMoving.cancel();

      element?.removeEventListener("wheel", handleWheel);
      element?.removeEventListener("touchmove", handleTouchMove);
    };
  };

  const setGridSize = (size: number) => {
    gridSize = size;
  };

  return { handleDown, handleMove, handleUp, mount, setGridSize };
};

type Engine = ReturnType<typeof createEngine>;

export const InfiniteGrid = ({
  gridSize,
  renderItem,
  className,
  initialPosition,
}: InfiniteGridProps) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [offset, setOffset] = useState<Position>(() => ({ ...(initialPosition ?? ORIGIN) }));
  const [isDragging, setIsDragging] = useState(false);
  const [isMoving, setIsMoving] = useState(false);
  const [gridItems, setGridItems] = useState<GridItem[]>([]);
  const [containerSize, setContainerSize] = useState<Size>(NO_SIZE);

  const engineRef = useRef<Engine | null>(null);
  if (engineRef.current === null) {
    engineRef.current = createEngine(initialPosition ?? ORIGIN, gridSize, {
      setContainerSize,
      setGridItems,
      setIsDragging,
      setIsMoving,
      setOffset,
    });
  }

  useEffect(() => {
    engineRef.current?.setGridSize(gridSize);
  }, [gridSize]);

  useEffect(() => engineRef.current?.mount(containerRef.current), []);

  const handleMouseDown = (e: ReactMouseEvent) => {
    engineRef.current?.handleDown({ x: e.clientX, y: e.clientY });
  };

  const handleMouseMove = (e: ReactMouseEvent) => {
    e.preventDefault();
    engineRef.current?.handleMove({ x: e.clientX, y: e.clientY });
  };

  const handleTouchStart = (e: ReactTouchEvent) => {
    const touch = e.touches.item(0);

    if (!touch) {
      return;
    }

    engineRef.current?.handleDown({ x: touch.clientX, y: touch.clientY });
  };

  const handleUp = () => {
    engineRef.current?.handleUp();
  };

  return (
    <div
      ref={containerRef}
      role="presentation"
      className={className}
      style={{
        cursor: isDragging ? "grabbing" : "grab",
        inset: 0,
        overflow: "hidden",
        position: "absolute",
        touchAction: "none",
      }}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
      onMouseUp={handleUp}
      onMouseLeave={handleUp}
      onTouchStart={handleTouchStart}
      onTouchEnd={handleUp}
      onTouchCancel={handleUp}
    >
      <div
        style={{
          inset: 0,
          position: "absolute",
          transform: `translate3d(${offset.x}px, ${offset.y}px, 0)`,
          willChange: "transform",
        }}
      >
        {gridItems.map((item) => {
          const x = item.position.x * gridSize + containerSize.width / 2;
          const y = item.position.y * gridSize + containerSize.height / 2;

          return (
            <div
              key={`${item.position.x}-${item.position.y}`}
              style={{
                alignItems: "center",
                display: "flex",
                height: gridSize,
                justifyContent: "center",
                marginLeft: `-${gridSize / 2}px`,
                marginTop: `-${gridSize / 2}px`,
                position: "absolute",
                transform: `translate3d(${x}px, ${y}px, 0)`,
                userSelect: "none",
                width: gridSize,
                willChange: "transform",
              }}
            >
              {renderItem({
                gridIndex: item.gridIndex,
                isMoving,
                position: item.position,
              })}
            </div>
          );
        })}
      </div>
    </div>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { motion } from "motion/react";

import type { GridItemConfig } from "./infinite-grid";
import { InfiniteGrid } from "./infinite-grid";

const Cell = ({ gridIndex }: GridItemConfig) => (
  <motion.div
    className="absolute inset-1 flex items-center justify-center"
    initial={{ opacity: 0, scale: 0 }}
    animate={{ opacity: 1, scale: 1 }}
    transition={{
      duration: 0.4,
      scale: { bounce: 0.5, type: "spring", visualDuration: 0.4 },
    }}
  >
    <img
      alt=""
      className="pointer-events-none size-20"
      src={`https://d24l2zb4cwkekfpl.public.blob.vercel-storage.com/illustrations/blueprint/%20${(gridIndex % 100) + 1}.svg`}
    />
  </motion.div>
);

const Preview = () => (
  <div className="h-screen w-screen bg-blue-50">
    <InfiniteGrid gridSize={150} renderItem={Cell} />
  </div>
);

export default Preview;
```
