A small commentary on implementing rotation in a web based design canvas
"Just add a CSS transform style with the rotation in degrees — done!" ...or so we thought!Published on: October 22, 2025A couple of months ago at work, I had the opportunity to implement rotation support in our web-based design canvas tool.
When we first scoped out the project, it seemed like a pretty simple feature to add: "Just add a CSS transform style with the rotation in degrees — done!" ...or so we thought!
Although visually, rendering these elements was fine, we quickly realised that we needed to handle things like collision detection within the canvas and other important editing actions like dragging the edge handles to resize elements.
Setting the scene
Rotation was something we introduced to our elements much later down the line in the product, and the design decisions we made prior to its introduction unfortunately meant that supporting it was not going to be a seamless affair.
In our canvas, we use a Cartesian coordinate system where the origin point, [0,0], is located at the top left. The data model for our positioned elements contain a position field which stores a top-left coordinate value relative to its container and then a dimension property containing the width and height.
interface CanvasElement {
position: {
top: number; // essentially the "y"
left: number; // the "x"
};
dimensions: {
width: number;
height: number;
};
rotation: number; // rotation in degrees
}
With this data model and elements always being upright, we could easily detect collision detections because each element effectively formed an axis-aligned bounding box (AABB).
For example, to check whether an element was within the x-bounds of its container, we would take the element’s left position as a starting point, add its width to find the end point, and then ensure that:
- The left position is greater than or equal to 0, and;
- The summed value position.left + dimensions.width does not exceed the container’s width.
This worked perfectly... until rotation knocked on the door!
A 90° clockwise rotated element would mean that the top-left point now is positioned visually in the top-right corner. Our collision detection logic at the time, which relied on fixed orientation, broke as the top-left point is no longer aligned to the left side of the container.
Solution: Adding a "geometry layer"
It become apparent that we needed to preserve the data modal as is and build a derived layer that could compute rotation-aware geometry on demand.
Enter the "geometry layer".
The goal was to introduce a small utility function that takes an element as input and returns a rotation-aware object with all the context needed for resizing and collision detection — nothing fancy!
At a high level, the function looked something like this:
export function getElementGeometryLayer({
position,
dimensions,
rotation,
}: CanvasElement): ElementGeometryLayer {
const center = getElementCenter(position, dimensions);
const corners = getElementCorners(position, dimensions, rotation);
const edges = getElementEdges(position, dimensions, rotation);
return {
obb: {
center,
corners,
edges,
},
};
}
The Oriented Bounding Box (OBB), describes the element’s actual geometry, factoring in rotation. It's essentially a bounding box that is rotated to match the element's orientation and will always match the dimension of the element too
In practice, to construct this, I created an object that returned back a context object of sorts:
center: [x,y] coordinate for the element’s centre pointcorners: Four corner coordinates in the specific order of — Top-left, Top-right, Bottom-right, Bottom-leftedges: Four edge definitions in this specific order — North, East, South, West, each including the start and end points, the distance between them, and their midpoint
These values were all rotation-aware and could be reused the canvas tool whenever needed.
Integration
With the OBB defined, the final step was to integrate this geometry layer into the builder’s existing logic for resizing and collision detection.
Rather than rewriting everything, we refactored existing calculations to consume this geometry layer instead of working directly with raw position and dimension data.
Calculating drag deltas for moving elements
When moving elements, we perform checks to ensure they remain within the bounds of their container.
Previously, these calculations assumed upright elements.
Using the geometry layer, we now retrieve corner values from the OBB and use them to validate whether the new position remains within bounds.
Resizing rotated elements
Before rotation support, resizing logic used drag deltas that weren’t rotation-aware.
This caused issues — for example, dragging what appeared to be the left edge (the west edge) of a 90° rotated element would incorrectly expect horizontal movement when the cursor was actually moving vertically.
To fix this, we apply a transformation to rotate the movement vector itself, making the drag deltas rotation-aware:
export function vectorRotate(vector: Vector, rotation: number): Vector {
const radians = (rotation * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
return {
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
};
}
Once that was in place, it was simply a matter of identifying which edge was being dragged and recalculating position and dimension accordingly.
The geometry layer was essential here — particularly the centre point, which we used to correctly offset position changes during resizing.
Collision detection
For collision detection, we switched from a simple rectangle-overlap approach to the Separating Axis Theorem (SAT), which supports rotated shapes.
This change was pretty straightforward once we had the algorithm implemented in a function — we just used the geometry layer to retrieve the necessary values (which was the corners in this case for the two elements in question) and it would return true or false depending on if a collision was active or not.
Closing statement
Through this additional geometry layer, we were able to introduce full rotation support without altering the core data model.
The implementation remained entirely behind the scenes, ensuring no downstream systems were affected.
By decoupling geometry from the raw data and moving it into a separate layer, we created a good foundation that will make future geometric behaviours a little easier to introduce.