July 12, 2026 at 07:53 PM EST
Replicating memorization sheets in React
July 12, 2026 at 07:53 PM EST
Replicating memorization sheets in React
Have you ever seen coloured memorization sheets? Some Japanese vocabulary books I’ve seen mark the key terms in red or green, so upon sliding identical-coloured sheets on top of them, they disappear! This post replicates those sheets in code through rectangle overlays we’ll be rendering in React.
Our initial goal is to render a rectangle and drag it across the viewport. We rely on the MouseEvents below.
The mousemove Event. This event fires when moving our cursor across the screen.
The mousedown Event. This event fires when clicking on an HTML element. The mouseUp is its counterpart.
We can pass handler functions, callbacks, to these MouseEvents to make any necessary calculations that suit our needs. In our case, we’ll want to calculate the positions before and after an element has been dragged. They’re also packed with relevant properties like clientX: the X coordinate within the viewport. I promise we’ll see them in action.
Our starter code includes a Sheet interface which holds the information of a div Element’s position, dimensions and colours. We create a sheet state variable and assign it a Sheet object. Its properties are then given to the style Attribute of our target element.
// x and y are the positions from the top left corner
// w and h are the width and height
import {useState} from "preact/hooks";
interface Sheet {
x: number;
y: number;
w: number;
h: number;
color: string;
opacity: number;
}
export default function Sheet() {
const [sheet, setSheet] = useState<Sheet>(
{
x: 0,
y:0,
w: 300,
h: 200,
color: "#3384ed",
opacity: 1
}
)
return (
<div className="outer-container">
<div
className="sheet mx-auto"
style = {{
left: sheet.x,
top: sheet.y,
width: sheet.w,
height: sheet.h,
background: sheet.color,
opacity: sheet.opacity,
}}
>
<p className="text-lg text-center">
Cannot be dragged.
</p>
</div>
</div>
)
}
Undraggable sheet
Cannot be dragged.
The next step is making that sheet above draggable. Once we click the sheet, we need to register its initial position and start tracking any cursor movement. We’ll need to establish a system of functions that link the mouseDown, mouseMove, and mouseUp Events or simply put: click, drag, release. Before moving on, all calculations will be relative to the viewport where the HTML coordinate system starts at the top left corner. Precisely, moving right increases x and moving down increases y.
The first stage involves keeping track of our initial click position. We create a DragData type to which we initialize a ref object that holds its kind. We use a ref because its information is only used for calculations and doesn’t need to trigger re-renders. A simpler explanation is clicking 1. The data stored on the initial click will be the offset from the original sheet position to a point clicked inside of it. It’s also where event Listeners mousemove and mouseup will be added.
Partial code skeleton
// ...
interface DragData {
offsetX: number;
offsetY: number;
}
const dragData = useRef<DragData | null>(null);
const handleMouseDown = (e: MouseEvent) => {
dragData.current = {
offsetX: e.clientX - sheet.x,
offsetY: e.clientY - sheet.y
};
// handleMouseMove and handleMouse up will be shown later.
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
<div
onMouseDown = {(e) => handleMouseDown}>
// ...
</div>
// ...
Assume your sheet’s top left corner sits at (100,100) initially relative to the viewport. Tapping 70px from the top left of the sheet will give us (e.clientX, e.clientY) = (170,170), the initial dragging point. Now, here’s an important distinction:
We drag from point where the user clicked, but we position the element using its top-left corner.
Precisely, if we drag the sheet from P1: (170,170) to P2: (200,400), we need to readjust the sheet’s top-left position by subtracting the original offset between the sheet’s top-left corner and the point where the user clicked within the sheet . Without calculating these offsets, every drag would cause the sheet’s top-left corner to automatically teleport to the cursor. It’s better seen in the figure below:
Figure 1: Calculating offsets and storing them in DragData
The event handler for mousemove, handleMouseMove, is in charge of adjusting the position of the top-left corner of our sheet as shown in the blue text explanation in Figure 1. To update the sheet’s position, we create a new one by shallow copying its properties with the spread operator and correcting the position with the offset from DragData. handleMouseup has a cleanup role, it removes the event listeners and resets the offset to null. Essentially, this is the drag and release.
const handleMouseMove = (e: MouseEvent) => {
if (dragData.current) {
const { offsetX, offsetY } = dragData.current;
setSheet((prev) => ({
...prev,
x: e.clientX - offsetX,
y: e.clientY - offsetY,
}));
}
const handleMouseUp = () => {
// The next point we drag the sheet from could be different!
dragData.current = null;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
Here’s the full code. I added a isDragging state to change the cursor image to a hand grab.
import {useState, useRef} from "preact/hooks";
interface Sheet {
x: number;
y: number;
w: number;
h: number;
color: string;
opacity: number;
}
interface DragData {
offsetX: number;
offsetY: number;
}
export default function SheetV2() {
const [isDragging, setIsDragging] = useState(false);
const [sheet, setSheet] = useState<Sheet>(
{
x: 0,
y:0,
w: 150,
h: 75,
color: "#3384ed",
opacity: 1
}
)
const dragData = useRef<DragData | null>(null);
const handleMouseDown = (e: MouseEvent) => {
dragData.current = {
offsetX: e.clientX - sheet.x,
offsetY: e.clientY - sheet.y
};
setIsDragging(true)
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
const handleMouseMove = (e: MouseEvent) => {
// We defined <DragData | null>, so we make sure it's not null before we access it.
if (dragData.current) {
const { offsetX, offsetY } = dragData.current;
setSheet( prev => ({
...prev,
x: e.clientX - offsetX,
y: e.clientY - offsetY
}))
}
}
const handleMouseUp = () => {
dragData.current = null;
setIsDragging(false)
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
return (
<div class="outer-container relative h-40 border-1 p-3">
<div
className="sheet"
onMouseDown = {(e) => handleMouseDown(e)}
style = {{
position: "absolute",
left: sheet.x,
top: sheet.y,
width: sheet.w,
height: sheet.h,
background: sheet.color,
opacity: sheet.opacity,
userSelect: "none",
zIndex: 1,
cursor: isDragging ? "grabbing" : "grab",
}}
>
</div>
<p className="relative text-lg z-50 text-center"> Drag across the <span className="text-[#3384ed]">blue text </span> </p>
</div>
)
}
I’ve added a stacking context with z-indices to the paragraph and sheet inside the outer-container in the interest of having the sheet behind the text.
On drag, we observe that:
Drag across the blue text
For completeness sake, let’s restrain the blue sheet to its outer container. To do so, we need the x and y coordinates of the outer container relative to the viewport. These are obtained with the getBoundingClientRect API, which only works on DOM elements. To manipulate the DOM directly in React, we’ll tag our outer-container with a containerRef to access it.
Tagging the outer-container, a lot of code left out for clarity
//...
const containerRef = useRef<HTMLDivElement>(null);
//...
<div
ref={containerRef}
className="outer-container relative h-40 border-1 p-3"
>
<div
className="sheet"
onMouseDown={(e) => handleMouseDown(e)}
style={{
position: "absolute",
left: sheet.x,
top: sheet.y,
width: sheet.w,
height: sheet.h,
background: sheet.color,
opacity: sheet.opacity,
userSelect: "none",
zIndex: 1,
cursor: isDragging ? "grabbing" : "grab",
}}
>
</div>
</div>
We adapt our click,drag, and release functions in consequence:
const handleMouseDown = (e: MouseEvent) => {
// Prevent the highlighting when dragging the rectangle
e.preventDefault();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
dragData.current = {
offsetX: e.clientX - rect.left - sheet.x,
offsetY: e.clientY - rect.top - sheet.y,
};
setIsDragging(true);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
// We defined <DragData | null>, so we make sure it's not null before we access it.
// We also need to check if the container ref because we declared in the type that it could be null.
if (dragData.current && containerRef.current) {
const { offsetX, offsetY } = dragData.current;
const rect = containerRef.current.getBoundingClientRect();
const rawX = e.clientX - offsetX - rect.left;
const rawY = e.clientY - offsetY - rect.top;
const clampedX = Math.max(0, Math.min(rawX, rect.width - sheet.w));
const clampedY = Math.max(0, Math.min(rawY, rect.height - sheet.h));
setSheet((prev) => ({
...prev,
x: clampedX,
y: clampedY,
}));
}
};
Drag across the blue text
To add more sheets, we can instead create an array of Sheet and assign an id to each.
No comments on this post yet. Be the first to share your wisdom :)