To implement a Figma-like zoom system for your canvas, you need to handle the following:
scale
and
translate
together to achieve a smooth experience.
Here's an updated implementation:
import { useEffect, useRef, useState } from 'react';
const App = () => {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const [transform, setTransform] = useState({
scale: 1,
translateX: 0,
translateY: 0,
});
const handleWheel = (e) => {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas || (!e.ctrlKey && !e.metaKey)) return;
const { scale, translateX, translateY } = transform;
// Determine the cursor position relative to the canvas
const { left, top } = canvas.getBoundingClientRect();
const mouseX = e.clientX - left;
const mouseY = e.clientY - top;
// Zoom in or out
const zoomFactor = 0.1;
const newScale = e.deltaY < 0 ? scale * (1 + zoomFactor) : scale * (1 - zoomFactor);
// Clamp the scale to prevent excessive zoom
const clampedScale = Math.max(0.2, Math.min(newScale, 5));
// Adjust translations to zoom around the mouse position
const deltaScale = clampedScale / scale;
const newTranslateX = translateX - (mouseX * (deltaScale - 1));
const newTranslateY = translateY - (mouseY * (deltaScale - 1));
setTransform({
scale: clampedScale,
translateX: newTranslateX,
translateY: newTranslateY,
});
};
const handleMouseDown = (e) => {
if (e.button !== 0) return; // Left mouse button only
const startX = e.clientX;
const startY = e.clientY;
const { translateX, translateY } = transform;
const handleMouseMove = (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
setTransform({
...transform,
translateX: translateX + deltaX,
translateY: translateY + deltaY,
});
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.addEventListener('wheel', handleWheel, { passive: false });
canvas.addEventListener('mousedown', handleMouseDown);
return () => {
canvas.removeEventListener('wheel', handleWheel);
canvas.removeEventListener('mousedown', handleMouseDown);
};
}, [transform]);
const { scale, translateX, translateY } = transform;
return (
<div
ref={containerRef}
style={{
width: '100vw',
height: '100vh',
overflow: 'hidden',
background: 'skyblue',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
ref={canvasRef}
style={{
width: '100vw',
height: '100vh',
background: 'violet',
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: '0 0',
cursor: 'grab',
}}
>
<span style={{ background: 'red', color: 'black' }}>this is the text</span>
</div>
</div>
);
};
export default App;
Zoom Around Cursor:
transformOrigin
is always 0 0
, and
translate
values are adjusted based on the cursor
position.
Panning:
mousedown
, mousemove
,
and mouseup
to drag the canvas.
Transform Management:
transform
state tracks the
scale
and translateX/translateY
for
proper rendering.
Keyboard Modifiers:
ctrlKey
or
metaKey
is pressed during a wheel event.
Let me know if you need further adjustments!
Sure! Let's break down the formula used to calculate the new translation values when zooming in or out, specifically this part:
const deltaScale = clampedScale / scale;
const newTranslateX = translateX - (mouseX * (deltaScale - 1));
const newTranslateY = translateY - (mouseY * (deltaScale - 1));
When zooming, the canvas is scaled using scale()
.
However, scaling always happens relative to the
transformOrigin
, which we set to
0 0
(top-left of the canvas). To ensure the zoom
"centers" around the cursor (the point where the user is pointing with
the mouse), we need to adjust the canvas's position (translateX
and translateY
) accordingly.
The goal is to "shift" the canvas so that the cursor stays fixed at the same spot in the viewport after the zoom.
deltaScale = clampedScale / scale
scale
: The current zoom level (before the wheel
event).
clampedScale
: The new zoom level (after applying
the zoom factor and clamping).
deltaScale
: The ratio between the new scale and
the current scale.
If deltaScale > 1
, we are zooming in (scale
increases).
If deltaScale < 1
, we are zooming out (scale
decreases).
This tells us by how much the scale is changing.
Adjusting Translations
To ensure that the canvas zooms around the cursor position, we
calculate how much to adjust the translations (translateX
and translateY
):
newTranslateX
):
newTranslateX = translateX - (mouseX * (deltaScale - 1));
translateX
: The current horizontal translation.
mouseX
: The cursor's X-coordinate relative to the
canvas's top-left corner.
deltaScale - 1
: The amount of change in scale.
For example:
deltaScale = 1.1
(zoom in),
deltaScale - 1 = 0.1
(10% increase).
deltaScale = 0.9
(zoom out),
deltaScale - 1 = -0.1
(10% decrease).
When zooming:
The mouseX
is multiplied by this scale change
(deltaScale - 1
) to calculate how much of the
canvas's width needs to shift to keep the cursor's position
consistent.
Subtracting this value from translateX
adjusts
the canvas's position so that the cursor remains fixed.
newTranslateY
):
newTranslateY = translateY - (mouseY * (deltaScale - 1));
translateY
and
mouseY
).
When you zoom in or out, without adjusting the translations, the
canvas would scale uniformly outward from the top-left corner (the
transformOrigin
). This would cause the point under the
cursor to move on the screen.
To counteract this movement, the formula:
-
), to "cancel out" the movement and keep the cursor
fixed.
Let's walk through an example:
scale = 1
translateX = 0
, translateY = 0
mouseX = 100
,
mouseY = 100
clampedScale
) = 1.2
(20% zoom
in)
deltaScale = 1.2 / 1 = 1.2
Now calculate the adjustments:
deltaScale - 1 = 1.2 - 1 = 0.2
newTranslateX = 0 - (100 * 0.2) = -20
newTranslateY = 0 - (100 * 0.2) = -20
Result:
translateX = -20
, translateY = -20
clampedScale
) = 0.8
(20% zoom
out)
deltaScale = 0.8 / 1 = 0.8
Now calculate the adjustments:
deltaScale - 1 = 0.8 - 1 = -0.2
newTranslateX = 0 - (100 * -0.2) = 20
newTranslateY = 0 - (100 * -0.2) = 20
Result:
translateX = 20
, translateY = 20
Let me know if you'd like further clarification!
Yes, it is absolutely possible to implement the zoom functionality for the content inside a fixed container, rather than the container itself. The scaling and translation can be applied directly to the content inside the container, while keeping the container fixed to the viewport.
Here’s how you can achieve this:
Fixed Container:
div
) will remain fixed to the
viewport.
Transform Applied to Content:
scale
and translate
)
are applied to the inner content.
Bounding Box Management:
Here’s the modified implementation:
import { useEffect, useRef, useState } from 'react';
const App = () => {
const containerRef = useRef(null);
const contentRef = useRef(null);
const [transform, setTransform] = useState({
scale: 1,
translateX: 0,
translateY: 0,
});
const handleWheel = (e) => {
e.preventDefault();
const content = contentRef.current;
if (!content || (!e.ctrlKey && !e.metaKey)) return;
const { scale, translateX, translateY } = transform;
// Get the cursor position relative to the content
const { left, top } = content.getBoundingClientRect();
const mouseX = e.clientX - left;
const mouseY = e.clientY - top;
// Zoom in or out
const zoomFactor = 0.1;
const newScale = e.deltaY < 0 ? scale * (1 + zoomFactor) : scale * (1 - zoomFactor);
// Clamp scale between 0.5 and 5
const clampedScale = Math.max(0.5, Math.min(newScale, 5));
// Calculate new translation values to zoom around the cursor
const deltaScale = clampedScale / scale;
const newTranslateX = translateX - (mouseX * (deltaScale - 1));
const newTranslateY = translateY - (mouseY * (deltaScale - 1));
setTransform({
scale: clampedScale,
translateX: newTranslateX,
translateY: newTranslateY,
});
};
const handleMouseDown = (e) => {
if (e.button !== 0) return; // Left mouse button only
const startX = e.clientX;
const startY = e.clientY;
const { translateX, translateY } = transform;
const handleMouseMove = (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
setTransform((prevTransform) => ({
...prevTransform,
translateX: translateX + deltaX,
translateY: translateY + deltaY,
}));
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
useEffect(() => {
const content = contentRef.current;
if (!content) return;
content.addEventListener('wheel', handleWheel, { passive: false });
content.addEventListener('mousedown', handleMouseDown);
return () => {
content.removeEventListener('wheel', handleWheel);
content.removeEventListener('mousedown', handleMouseDown);
};
}, [transform]);
const { scale, translateX, translateY } = transform;
return (
<div
ref={containerRef}
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
overflow: 'hidden',
background: 'skyblue',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
ref={contentRef}
style={{
width: '2000px', // Content width (can be larger than the container)
height: '2000px', // Content height (can be larger than the container)
background: 'violet',
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: '0 0',
cursor: 'grab',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ background: 'red', color: 'black' }}>this is the text</span>
</div>
</div>
);
};
export default App;
Container Setup:
containerRef
) is
fixed to the viewport using
position: fixed
and overflow: hidden
.
Scaling Content:
transform
(scale
and
translate
) is applied to the
contentRef
(inner content), leaving the container
unaffected.
transformOrigin
is set to 0 0
for
proper translation calculations.
Cursor Position for Zoom:
translateX
and
translateY
.
Large Content:
contentRef
dimensions (2000px
by
2000px
) are larger than the viewport, allowing
meaningful panning and zooming.
Zoom:
scale
and
translate
values.
Pan:
translateX
and
translateY
to move the content around within the
fixed container.
Bounds Check:
translateX
and
translateY
values to prevent this.
Touch Support:
Reset Button:
scale
and
translate
values to their defaults.
Would you like me to implement any of these enhancements?
CSS scale()
Affects an Element's Position Relative
to the Viewport
The scale()
property in CSS transforms an element by
resizing it (making it larger or smaller) relative to its
transform origin. Here's a detailed explanation:
CSS scale()
Transform Origin:
transform-origin
property determines the
pivot point around which the element scales.
50% 50%
(center of the
element), but it can be set to any point (e.g.,
0 0
for top-left, or a specific pixel position).
Scaling:
scale(sx, sy)
:
sx
: Horizontal scaling factor.sy
: Vertical scaling factor (optional; defaults
to sx
).
Viewport Impact:
0 0
).
50% 50%
) as the origin, its position relative to the viewport will
appear to "move" as it scales outward from the center.
Cumulative Effects:
translate
, rotate
, etc.).
In our case, the goal is to scale the content inside the fixed container while maintaining the cursor's relative position as the scaling center. Here's how this was achieved:
To implement zooming and panning logic using scale
:
We use the scale
property to zoom the inner content
(contentRef
) while ensuring:
The transform:
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
applies both scaling and translations. Scaling resizes the content, and translations shift it so the cursor remains stable.
When zooming in, the content visually expands outward. To make it appear as though the zoom is centered on the cursor:
const mouseX = e.clientX - left; // Relative to the content's top-left
const mouseY = e.clientY - top;
0 0
(top-left),
scaling affects the content uniformly outward from that point. We
adjust the translateX
and translateY
to
counteract this shift.
When scaling changes:
const deltaScale = clampedScale / scale;
const newTranslateX = translateX - (mouseX * (deltaScale - 1));
const newTranslateY = translateY - (mouseY * (deltaScale - 1));
Here's what's happening:
deltaScale = clampedScale / scale
:
scale
increases from 1
to
1.2
, deltaScale = 1.2
).
Calculate the Shift Due to Scale:
mouseX
) is affected by the scale change.
(deltaScale - 1)
gives the amount of scale change.
deltaScale > 1
), this is a
positive number.
deltaScale < 1
), this is
negative.
Compensate for the Shift:
mouseX
,
mouseY
) by the scale change to calculate how much
the element would "move."
translateX
, translateY
) to cancel out
the movement.
This ensures that the visual scaling appears centered on the cursor.
To implement panning:
translateX
and
translateY
directly:
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
setTransform((prevTransform) => ({
...prevTransform,
translateX: prevTransform.translateX + deltaX,
translateY: prevTransform.translateY + deltaY,
}));
This shifts the content relative to the drag distance
(deltaX
, deltaY
).
Scale Logic:
scale
to zoom the content.Translation Logic:
translateX
and translateY
with
scale
to allow independent panning and zooming.
Interaction Between Scale and Translate:
This combination of scale
and
translate
provides a flexible and intuitive zoom-and-pan
effect. Let me know if you need further clarification or enhancements!
Great question! Let’s break it down to understand
how scaling affects the position of an element in relation to the
viewport, especially for different values of
transform-origin
(like 50% 50%
and
0 0
), and
why we need to apply translations to cancel the shift caused by
scaling.
When we apply a scale()
transformation to an element, the
size of the element changes. However,
the position of the element in the viewport appears to change if
the transform-origin
is not the top-left corner (0 0
). Here’s why:
Scaling Around Transform-Origin:
scale()
operation enlarges or shrinks the
element around the transform-origin
, which acts as the pivot point.
transform-origin
is not at the top-left
(0 0
), the scaling will "pull" or "push" the
element away from its original position.
Viewport Perception:
transform-origin
Values
transform-origin: 0 0
(Top-Left Corner)
transform-origin: 50% 50%
(Center of the Element)
transform-origin: 50% 50%
When scaling an element with transform-origin: 50% 50%
,
the following happens:
scale(2)
), the top-left corner
"moves"
half the amount of the element's growth
outward.
scale(0.5)
), the top-left
corner "moves"
half the amount of the element's shrinkage
inward.
This movement is proportional to the size change and needs to be corrected by translation.
To cancel the shift caused by scaling for any
transform-origin
, we calculate the translation required.
Delta Scale:
deltaScale = newScale - 1
(e.g., for scale(2)
,
deltaScale = 1
because the element doubles in size.)
Translation for transform-origin: 50% 50%
:
translateX = elementWidth * deltaScale * 0.5
translateY = elementHeight * deltaScale * 0.5
Translation for transform-origin: 0 0
:
transform-origin: 0 0
with
scale(2)
100px x 100px
200px x 200px
transform-origin: 50% 50%
with
scale(2)
100px x 100px
200px x 200px
plaintext translateX = 100 * (2 - 1) * 0.5 = 50px
plaintext translateY = 100 * (2 - 1) * 0.5 = 50px
transform: translate(-50px, -50px) scale(2);
transform-origin: 25% 25%
with
scale(2)
100px x 100px
200px x 200px
25% 25%
:
plaintext translateX = 100 * (2 - 1) * 0.25 = 25px
plaintext translateY = 100 * (2 - 1) * 0.25 = 25px
transform: translate(-25px, -25px) scale(2);
In your implementation:
transform-origin
(in this case, 0 0
or the
cursor itself).
translateX = translateX - (mouseX * (deltaScale - 1));
translateY = translateY - (mouseY * (deltaScale - 1));
adjusts the translation to cancel out the perceived movement caused by scaling.
Would you like to dive deeper into any part of this? Let me know!