Building a Chess Web App

In this article, we will be building a Chess application using React that will allow the player to drag pieces across the board. We'll be using TailwindCSS for styling. To view what we will be building by the end of this article, visit mathewbushuru.com/experiments/0001 .

The starting code is shown below:

import Chessboard from "@/components/chessboard";
 
export default function App() {
  return (
    <div>
      <Chessboard />
    </div>
  );
}

We will then create a helper function that renders all the 64 squares of the chessboard. This function is called by the Chessboard component to create all the square components that are then rendered on a 500px by 500px grid (320px by 320px on mobile).

function renderSquares() {
  const squares = [];
 
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      squares.push(
        <div className="text-xs" key={`${row}${col}`}>
          {row},{col}
        </div>
      );
    }
  }
 
  return squares;
}
 
export default function Chessboard() {
  const boardSquares = renderSquares();
  return (
    <div className="grid h-80 w-80 grid-cols-8 grid-rows-8 sm:h-[500px] sm:w-[500px]">
      {boardSquares}
    </div>
  );
}

After adding this, this is how the app looks like:

Chessboard  at start

Next we will add altenating dark and light squares to the chessboard. We will be using a helper function (cn) that uses Tailwind-merge and Clsx to conditionally apply tailwind classes when the square is dark.

import { type ReactNode } from "react";
 
import { cn } from "@/lib/utils";
 
function Square({
  isDark,
  children,
}: {
  isDark: boolean;
  children: ReactNode;
}) {
  return (
    <div
      className={cn(
        "flex items-center justify-center",
        isDark && "bg-gray-200",
       )}
    >
      {children}
    </div>
    );
 }
 
function renderSquares() {
  const squares = [];
 
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      squares.push(
        <Square isDark={(row + col) % 2 === 1}>
          {row},{col}
        </Square>
      );
    }
  }
 
  return squares;
}
 
export default function Chessboard() {
  const boardSquares = renderSquares();
  return (
    <div className="grid h-80 w-80 grid-cols-8 grid-rows-8 sm:h-[500px] sm:w-[500px]">
      {boardSquares}
    </div>
  );
}
Chessboard

We will be maintaining the positional data of the chess pieces in the Chessboard component. We pass down this data to the renderSquares function which uses it to add chess piece icons to the relevant squares. To simplify this, we create a lookup table that returns a Piece component that has the relevant icon image.

import { type ReactElement, type ReactNode } from "react";
 
import { cn } from "@/lib/utils";
 
function Piece({ imageSrc, alt }: { imageSrc: string; alt: string }) {
  return <img src={imageSrc} alt={alt} className="h-11 w-11" />;
}
 
type TPieceName = "king" | "pawn";
 
const pieceComponentLookup: {
  [Key in TPieceName]: () => ReactElement;
} = {
  king: () => <Piece imageSrc="/icons/king.png" alt="King" />,
  pawn: () => <Piece imageSrc="/icons/pawn.png" alt="Pawn" />,
};
 
type TCoordsArr = [number, number];
 
type TPieceData = {
  name: TPieceName;
  coords: TCoordsArr;
};
 
function areCoordsEqual(c1: TCoordsArr, c2: TCoordsArr) {
  return c1[0] === c2[0] && c1[1] === c2[1];
}
 
function Square({
  isDark,
  children,
}: {
  isDark: boolean;
  children: ReactNode;
}) {
  return (
    <div
      className={cn(
        "flex items-center justify-center",
        isDark && "bg-gray-200",
       )}
    >
      {children}
    </div>
    );
 }
 
function renderSquares(allPiecesData: TPieceData[]) {
  const squares = [];
 
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      const currCoordsArr: TCoordsArr = [row, col];
 
      const pieceInThisCoord = allPiecesData.find((pieceData) =>
        areCoordsEqual(currCoordsArr, pieceData.coords),
      );
 
      let ComponentToRender;
      if (pieceInThisCoord) {
        ComponentToRender = pieceComponentLookup[pieceInThisCoord.name];
        squares.push(
          <Square isDark={(row + col) % 2 === 1} key={`${row}${col}`}>
            <ComponentToRender />
          </Square>,
        );
      } else {
        ComponentToRender = () => <>&nbsp;</>;
        squares.push(
          <Square isDark={(row + col) % 2 === 1} key={`${row}${col}`}>
            <ComponentToRender />
          </Square>,
        );
      }
    }
  }
 
  return squares;
}
 
export default function Chessboard() {
  const allPiecesData: TPieceData[] = [
    { name: "king", coords: [3, 2] },
    { name: "pawn", coords: [1, 6] },
  ];
 
  const boardSquares = renderSquares(allPiecesData);
 
  return (
    <div className="grid h-80 w-80 grid-cols-8 grid-rows-8 sm:h-[500px] sm:w-[500px]">
      {boardSquares}
    </div>
  );
}
Chessboard

We will now make our chess board functional by allowing chess pieces to be dragged around. We will be using Pragmatic Drag and Drop from Atlassian for our drag-and-drop functionality. This library provides a Draggable function that we can attach to any HTML element in the page that we want make draggable (the chess icon images in our case). To make the chess piece icon feel like it is being dragged, we will reduce the opacity of the origin element to 40%. To get started run the command below to install the package then add the following code to what we have currently.

yarn add @atlaskit/pragmatic-drag-and-drop
import {
 useRef,
 useEffect,
 useState,
 type ReactElement,
 type ReactNode,
} from "react";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
 
import { cn } from "@/lib/utils";
 
function Piece({ imageSrc, alt }: { imageSrc: string; alt: string }) {
  const pieceRef = useRef<HTMLImageElement | null>(null);
  const [isDragging, setIsDragging] = useState<boolean>(false);
 
  useEffect(() => {
    const el = pieceRef.current;
 
    if (el === null) return;
 
    return draggable({
      element: el,
      onDragStart: () => setIsDragging(true),
      onDrop: () => setIsDragging(false),
     });
  }, []);
 
  return (
    <img
      ref={pieceRef}
      src={imageSrc}
      alt={alt}
      className={cn("h-11 w-11", isDragging && "opacity-40")}
    />
  );
}
 
type TPieceName = "king" | "pawn";
 
// ...

As shown below, we can now drag the individual chess pieces and the squares they were dragged from fades.

Chessboard

Now that the pieces can be dragged, we will make the squares of the chess board act as areas that can be dropped into. We will also highlight the squares in a different colour when dragged over to provide visual feedback to the user.

import {
  useRef,
  useEffect,
  useState,
  type ReactElement,
  type ReactNode,
} from "react";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
 
import { cn } from "@/lib/utils";
 
function Piece({ imageSrc, alt }: { imageSrc: string; alt: string }) {
  // ...
}
 
// ...
 
function Square({
  isDark,
  children,
}: {
  isDark: boolean;
  children: ReactNode;
}) {
  const squareRef = useRef<HTMLDivElement | null>(null);
  const [isDraggedOver, setIsDraggedOver] = useState(false);
 
  useEffect(() => {
    const el = squareRef.current;
 
    if (el === null) return;
 
    return dropTargetForElements({
      element: el,
      onDragEnter: () => setIsDraggedOver(true),
      onDragLeave: () => setIsDraggedOver(false),
      onDrop: () => setIsDraggedOver(false),
    });
  }, []);
 
  return (
    <div
      ref={squareRef}
      className={cn(
        "flex items-center justify-center",
        isDraggedOver ? "bg-sky-200" : isDark ? "bg-gray-200" : "bg-popover",
      )}
    >
      {children}
    </div>
  );
}
 
function renderSquares(allPiecesData: TPieceData[]) {
   // ... 
}
 
export default function Chessboard() {
   // ... 
}

As you can see below, a square is highlighted in blue when a chess piece is dragged over it.

Chessboard

We can go further and highlight a square green if it is eligible to be dropped onto and red when its not. For this to work, each draggable piece will have to carry its original coordinates with it when it is being dragged. We modify the Piece component to accept coordinates and pass it on to the getInitialData field of the arguments object taken by draggable.

import {
  useRef,
  useEffect,
  useState,
  type ReactElement,
  type ReactNode,
} from "react";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
 
import { cn } from "@/lib/utils";
 
type TPieceName = "king" | "pawn";
type TCoordsArr = [number, number];
 
function Piece({
  name,
  coords,
  imageSrc,
  alt,
}: {
  name: TPieceName;
  coords: TCoordsArr;
  imageSrc: string;
  alt: string;
}) {
  const pieceRef = useRef<HTMLImageElement | null>(null);
  const [isDragging, setIsDragging] = useState<boolean>(false);
 
  useEffect(() => {
    const el = pieceRef.current;
 
    if (el === null) return;
 
    return draggable({
      element: el,
      getInitialData: () => ({ name, coords }),
      onDragStart: () => setIsDragging(true),
      onDrop: () => setIsDragging(false),
    });
  }, [name, coords]);
 
  return (
    <img
      ref={pieceRef}
      src={imageSrc}
      alt={alt}
      className={cn("h-11 w-11", isDragging && "opacity-40")}
    />
  );
}
 
const pieceComponentLookup: {
  [Key in TPieceName]: (coords: TCoordsArr) => ReactElement;
} = {
  king: (coords) => (
    <Piece name="king" coords={coords} imageSrc="/icons/king.png" alt="King" />
  ),
  pawn: (coords) => (
    <Piece name="pawn" coords={coords} imageSrc="/icons/pawn.png" alt="Pawn" />
  ),
};
 
type TPieceData = {
  name: TPieceName;
  coords: TCoordsArr;
};
 
function areCoordsEqual(c1: TCoordsArr, c2: TCoordsArr) {
  // ...
}
 
function Square({isDark, children}: {isDark: boolean; children: ReactNode;}){
  // ...
}
 
function renderSquares(allPiecesData: TPieceData[]) {
  const squares = [];
 
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      const currCoordsArr: TCoordsArr = [row, col];
 
      const pieceInThisCoord = allPiecesData.find((pieceData) =>
                                    areCoordsEqual(currCoordsArr, pieceData.coords),
                                ); 
 
      squares.push(
        <Square isDark={(row + col) % 2 === 1} key={`${row}${col}`}>
          {pieceInThisCoord ? (
            pieceComponentLookup[pieceInThisCoord.name](currCoordsArr)
          ) : (
            <>&nbsp;</>
          )}
        </Square>,
      );
    }
  }
 
  return squares;
}
 
export default function Chessboard() {
  // ...
}

We can use this starting location and piece name at our drop targets (Square components). We also introduce an isMoveValid function that checks if a chess piece can be dropped onto a particular square based on its start and end location, and the piece type.

The TypeScript types for the chess piece's name and coordinates are not carried over from the draggable ( Piece component) to the drop target (Square component). To make Typescript happy, we'll be using a temporary escape hatch (The 'as' type assertion to cast them to their expected types). Later on, we'll implement a solution for this using type guards which are functions or expressions that perform a runtime check to guarantee the types in a certain scope.

import {
  useRef,
  useEffect,
  useState,
  type ReactElement,
  type ReactNode,
} from "react";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
 
import { cn } from "@/lib/utils";
 
type TPieceName = "king" | "pawn";
type TCoordsArr = [number, number];
 
function Piece({ // ... } : { // ... }) {
  // ...
}
 
const pieceComponentLookup: {
  [Key in TPieceName]: (coords: TCoordsArr) => ReactElement;
} = {
  // ...
};
 
type TPieceData = {
  name: TPieceName;
  coords: TCoordsArr;
};
 
function areCoordsEqual(c1: TCoordsArr, c2: TCoordsArr) {
  // ...
}
 
function isMoveValid(
  startCoords: TCoordsArr,
  destCoords: TCoordsArr,
  pieceName: TPieceName,
) {
  const rowDist = Math.abs(startCoords[0] - destCoords[0]);
  const colDist = Math.abs(startCoords[1] - destCoords[1]);
 
  switch (pieceName) {
    case "king":
      return [0, 1].includes(rowDist) && [0, 1].includes(colDist);
    case "pawn":
      return colDist === 0 && startCoords[0] - destCoords[0] === 1;
    default:
      return false;
  }
}
 
function Square({coords, children}: {isDark: TCoordsArr; children: ReactNode;}){
  type THoveredState = "idle" | "validMove" | "invalidMove";
 
  const squareRef = useRef<HTMLDivElement | null>(null);
  const [hoveredState, setHoveredState] = useState<THoveredState>("idle");
 
  const isDark = (coords[0] + coords[1]) % 2 === 1;
 
  useEffect(() => {
    const el = squareRef.current;
 
    if (el === null) return;
 
    return dropTargetForElements({
      element: el,
      onDragEnter: ({ source: draggedPiece }) => {
        const startCoords = draggedPiece.data.coords as TCoordsArr;
        const pieceName = draggedPiece.data.name as TPieceName;
        if (isMoveValid(startCoords, coords, pieceName)) {
          setHoveredState("validMove");
        } else {
          setHoveredState("invalidMove");
        }
      },
      onDragLeave: () => setHoveredState("idle"),
      onDrop: () => setHoveredState("idle"),
    });
  }, []);
 
  return (
    <div
      ref={squareRef}
      className={cn(
        "flex items-center justify-center",
        hoveredState === "validMove"
          ? "bg-emerald-200"
            : hoveredState === "invalidMove"
              ? "bg-rose-200"
                : isDark
                  ? "bg-gray-200"
                  : "bg-popover",
      )}
    >
      {children}
    </div>
  );
}
 
function renderSquares(allPiecesData: TPieceData[]) {
  const squares = [];
 
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      const currCoordsArr: TCoordsArr = [row, col];
 
      const pieceInThisCoord = allPiecesData.find((pieceData) =>
                                    areCoordsEqual(currCoordsArr, pieceData.coords),
                                ); 
 
      squares.push(
        <Square coords={currCoordsArr} key={`${row}${col}`}>
          {pieceInThisCoord ? (
            pieceComponentLookup[pieceInThisCoord.name](currCoordsArr)
          ) : (
            <>&nbsp;</>
          )}
        </Square>,
      );
    }
  }
 
  return squares;
}
 
export default function Chessboard() {
  // ...
}

We can now move the chess pieces and the square on the chessboard will show green if it is a valid move and red when it is not. However, one crucial piece is still missing. If you drop a piece, it goes back to its original location. We will fix this using the monitorForElements function from Pragmatic drag and drop, which will enable us to update the locations/coordinates states of all pieces after any drag and drop operation. We modify the Square component to extract the coords data that we attached to the Piece component above and then add the monitorForElements function to the Chessboard component. We also replace the allPiecesData array with a React state variable.

// ...
import {draggable, dropTargetForElements, monitorForElements} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
 
// ...
 
function Square({coords, children}: {isDark: TCoordsArr; children: ReactNode;}){
  type THoveredState = "idle" | "validMove" | "invalidMove";
 
  const squareRef = useRef<HTMLDivElement | null>(null);
  const [hoveredState, setHoveredState] = useState<THoveredState>("idle");
 
  const isDark = (coords[0] + coords[1]) % 2 === 1;
 
  useEffect(() => {
    const el = squareRef.current;
 
    if (el === null) return;
 
    return dropTargetForElements({
      element: el,
      onDragEnter: ({ source: draggedPiece }) => {
        const startCoords = draggedPiece.data.coords as TCoordsArr;
        const pieceName = draggedPiece.data.name as TPieceName;
        if (isMoveValid(startCoords, coords, pieceName)) {
          setHoveredState("validMove");
        } else {
          setHoveredState("invalidMove");
        }
      },
      onDragLeave: () => setHoveredState("idle"),
      onDrop: () => setHoveredState("idle"),
      getData: () => ({ coords }),
    });
  }, []);
 
  return (
    <div
      ref={squareRef}
      className={cn(
        "flex items-center justify-center",
        hoveredState === "validMove"
          ? "bg-emerald-200"
            : hoveredState === "invalidMove"
              ? "bg-rose-200"
                : isDark
                  ? "bg-gray-200"
                  : "bg-popover",
      )}
    >
      {children}
    </div>
  );
}
 
export default function Chessboard() {
  const [allPiecesData, setAllPiecesData] = useState<TPieceData[]>([
    { name: "king", coords: [7, 3] },
    { name: "pawn", coords: [6, 5] },
  ]);
 
  useEffect(() => {
    return monitorForElements({
      onDrop: ({ source, location }) => {
        const destination = location.current.dropTargets[0];
 
        if (!destination) return;
 
        const sourceCoords = source.data.coords as TCoordsArr;
        const destCoords = destination.data.coords as TCoordsArr;
 
        const draggedPieceData = allPiecesData.find((p) =>
          areCoordsEqual(p.coords, sourceCoords),
        );
 
        if (!draggedPieceData) return;
 
        const restOfPiecesData = allPiecesData.filter(
          (p) => p !== draggedPieceData,
        );
 
        if (isMoveValid(sourceCoords, destCoords, draggedPieceData.name)) {
          setAllPiecesData([
            { name: draggedPieceData.name, coords: destCoords },
            ...restOfPiecesData,
          ]);
        }
      },
    });
  }, [allPiecesData]);
 
  const boardSquares = renderSquares(allPiecesData);
 
  return (
    <div className="grid h-80 w-80 grid-cols-8 grid-rows-8 sm:h-[500px] sm:w-[500px]">
      {boardSquares}
    </div>
  );
}

We now have working Chessboard, albeit a pretty limited one as it only has two pieces.

Play with the app we built at mathewbushuru.com/experiments/0001 .

This article was last modified on August 5, 2024.