Docs
Art Board Layout

Art Board Layout

A lightweight and performant draggable layout system with grid snapping and smooth animations.

Basic Layout

Styled Layout

Installation

Copy and paste the following code into your project.

components/ui/art-board-layout.tsx

"use client";
 
import React, { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
 
interface ArtBoardLayoutProps {
  gridSize?: number;
  borderWidth?: "thin" | "medium" | "thick";
  borderStyle?: "modern" | "classic" | "rustic";
  backgroundColor?: string;
  gridColor?: string;
  snapToGrid?: boolean;
  minHeight?: string;
  showGridLines?: boolean;
  className?: string;
  children?: React.ReactNode;
}
 
interface Position {
  x: number;
  y: number;
}
 
interface DraggableProps {
  children: React.ReactNode;
  onPositionChange?: (position: Position) => void;
  initialPosition?: Position;
  gridSize?: number;
  snapToGrid?: boolean;
}
 
const Draggable = ({
  children,
  onPositionChange,
  initialPosition = { x: 0, y: 0 },
  gridSize = 20,
  snapToGrid = true,
}: DraggableProps) => {
  const elementRef = useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = useState(false);
  const [position, setPosition] = useState<Position>(initialPosition);
  const dragStart = useRef<Position>({ x: 0, y: 0 });
  const elementStart = useRef<Position>({ x: 0, y: 0 });
 
  const handleMouseDown = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      if (e.button !== 0) return; // Only left click
      setIsDragging(true);
      dragStart.current = { x: e.clientX, y: e.clientY };
      elementStart.current = position;
      e.stopPropagation();
    },
    [position],
  );
 
  const handleMouseMove = useCallback(
    (e: MouseEvent) => {
      if (!isDragging) return;
 
      let newX = elementStart.current.x + (e.clientX - dragStart.current.x);
      let newY = elementStart.current.y + (e.clientY - dragStart.current.y);
 
      if (snapToGrid) {
        newX = Math.round(newX / gridSize) * gridSize;
        newY = Math.round(newY / gridSize) * gridSize;
      }
 
      const newPosition = { x: newX, y: newY };
      setPosition(newPosition);
      onPositionChange?.(newPosition);
    },
    [isDragging, gridSize, snapToGrid, onPositionChange],
  );
 
  const handleMouseUp = useCallback(() => {
    setIsDragging(false);
  }, []);
 
  useEffect(() => {
    if (isDragging) {
      window.addEventListener("mousemove", handleMouseMove);
      window.addEventListener("mouseup", handleMouseUp);
      return () => {
        window.removeEventListener("mousemove", handleMouseMove);
        window.removeEventListener("mouseup", handleMouseUp);
      };
    }
  }, [isDragging, handleMouseMove, handleMouseUp]);
 
  return (
    <div
      ref={elementRef}
      style={{
        position: "absolute",
        transform: `translate(${position.x}px, ${position.y}px)`,
        cursor: isDragging ? "grabbing" : "grab",
        userSelect: "none",
        zIndex: isDragging ? 1000 : 1,
      }}
      onMouseDown={handleMouseDown}
    >
      {children}
    </div>
  );
};
 
const borderStyles = {
  modern: "bg-linear-to-br from-gray-50 to-gray-100 shadow-xs",
  classic: "bg-linear-to-br from-amber-50 to-amber-100 shadow-inner",
  rustic: "bg-linear-to-br from-stone-100 to-stone-200 shadow-inner",
};
 
const borderWidths = {
  thin: "p-4",
  medium: "p-6",
  thick: "p-8",
};
 
export function ArtBoardLayout({
  gridSize = 20,
  borderWidth = "medium",
  borderStyle = "modern",
  backgroundColor = "white",
  gridColor = "rgba(0, 0, 0, 0.05)",
  snapToGrid = true,
  minHeight = "400px",
  showGridLines = true,
  className,
  children,
}: ArtBoardLayoutProps) {
  const boardRef = useRef<HTMLDivElement>(null);
  const [elements, setElements] = useState<React.ReactNode[]>([]);
 
  useEffect(() => {
    if (!children) return;
    const childArray = React.Children.toArray(children);
    setElements(
      childArray.map((child, index) => (
        <Draggable
          key={index}
          gridSize={gridSize}
          snapToGrid={snapToGrid}
          initialPosition={{ x: index * gridSize * 2, y: gridSize * 2 }}
        >
          {child}
        </Draggable>
      )),
    );
  }, [children, gridSize, snapToGrid]);
 
  const gridBackground = showGridLines
    ? {
        backgroundImage: `
          linear-gradient(to right, ${gridColor} 1px, transparent 1px),
          linear-gradient(to bottom, ${gridColor} 1px, transparent 1px)
        `,
        backgroundSize: `${gridSize}px ${gridSize}px`,
      }
    : {};
 
  return (
    <div
      ref={boardRef}
      className={cn(
        "relative overflow-hidden rounded-xl border transition-colors",
        "hover:shadow-lg",
        "focus-within:ring-2 focus-within:ring-primary/20",
        borderStyles[borderStyle],
        borderWidths[borderWidth],
        className,
      )}
      style={{
        minHeight,
        backgroundColor,
        ...gridBackground,
      }}
    >
      {elements}
    </div>
  );
}

Update the import paths to match your project setup.

import { ArtBoardLayout } from "@/components/ui/art-board-layout";

Examples

Basic Usage

export function BasicBoard() {
  return (
    <ArtBoardLayout className="min-h-[300px] w-96">
      <div className="rounded-lg bg-gray-500/10 p-6 backdrop-blur-xs">
        <h3 className="font-semibold text-black/80">Drag me!</h3>
        <p className="text-sm text-muted-foreground">Use mouse to drag</p>
      </div>
    </ArtBoardLayout>
  );
}

Custom Styling

export function StyledBoard() {
  return (
    <ArtBoardLayout
      borderStyle="rustic"
      borderWidth="thick"
      gridColor="rgba(0, 0, 255, 0.03)"
      backgroundColor="#fafafa"
      gridSize={40}
      className="min-h-[300px]"
    >
      <div className="rounded-lg bg-emerald-500/10 p-6 backdrop-blur-xs">
        <h3 className="font-semibold text-black/80">Custom Grid</h3>
        <p className="text-sm text-muted-foreground">40px grid size</p>
      </div>
    </ArtBoardLayout>
  );
}

Props

ArtBoardLayout

PropTypeDefaultDescription
gridSizenumber20Size of the background grid in pixels
borderWidth"thin" | "medium" | "thick""medium"Width of the mounting board border
borderStyle"modern" | "classic" | "rustic""modern"Style of the mounting board border
backgroundColorstring"white"Background color of the board
gridColorstring"rgba(0, 0, 0, 0.05)"Color of the grid lines
snapToGridbooleantrueWhether items should snap to grid when dropped
showGridLinesbooleantrueWhether to show the background grid
minHeightstring"400px"Minimum height of the layout board
classNamestring-Additional CSS classes for styling
childrenReact.ReactNode-Draggable content elements