TZWZ's personal page
Zooming-in and moving around a website element in DOM
19.08.2026
JavaScript

When navigating around the internet, you may, eventually, arrive at some website with an interactive map, like OpenStreetMap, that allows you to change zoom level and move around it. On other websites, you may encounter image viewers allowing you to do similar things with an image. The zoom level is usually changed by using the mouse wheel on an element (a map or an image). By clicking the mouse button on the element and dragging the mouse around, the thing moves as you movethe mouse.

Here I want to implement such a thing. I needed it a couple of times for some hobby projects, so it's probably a good idea to do a write-up to never have to think about it again.

Final effect video

This is the final effect of what we will try to accomplish here:

Required HTML code

First, we will need an HTML document that will show our image or map. I will be using an image here, but the inner element can be basically anything you want, as we only modify the outer element to scale and move everything inside it.

<div
  id="container"
  style="overflow:hidden; position:relative; width: 300px; height: 300px;"
>
  <div
    id="zoom_content"
    style="position: absolute;transform-origin: 0 0;"
  >
    <img src="hamster.png">
  </div>
</div>

The width and height of the container need to be set, because otherwise it will have 0px for both, as zoom_content has position: absolute. They can be 100vw and 100vh if you want the container to take up the entire screen.

transform-origin is needed so that all transformations are relative to the top-left corner. Otherwise, element's top and left values change after scaling, which will break the alignment.

Code setup

Let's start writing the code to make the element interactive.

const zoomSpeedModifier = -0.003;
let container = null
let zoomTarget = null
let scale = 1.0
let shift = { x: 0, y: 0 }

window.onload = () => {
  container = document.getElementById("container");
  zoomTarget = document.getElementById("zoom_content")
  container.addEventListener("wheel", (ev) => {
    ev.preventDefault();
    const mousePos = { x: ev.clientX, y: ev.clientY };
    zoomAndShift(mousePos, ev.deltaY * zoomSpeedModifier);
  });
}

We declare the initial scale and shift of the element. The shift represents how much the displayed thing is moved from the initial position (i.e. 0, 0). Basically, it's its top and left CSS value.

Then we add the wheel event to container, as the item in it may be zoomed out so far that we use the mouse wheel on container but not on zoom_content.

zoomAndShift needs to be implemented next. Let's get to this.

Zooming an element and centering it around a point

zoomAndShift takes information about which point needs to stay where it is and how much to scale (zoom in/out) the element. It then should scale the element and move it so that the provided point remains where it is.

function zoomAndShift(focusedPoint, scaleChange) {
  const rect = container.getBoundingClientRect();
  // Where we are inside container,
  // how far from top-left point of it (rect)
  // and in one step where we are in target element (shift)
  // we take care of scale later on
  const elPos = {
    x: focusedPoint.x - rect.left - shift.x,
    y: focusedPoint.y - rect.top - shift.y,
  };
  // Rescale
  const oldScale = scale;
  scale += scaleChange;
  scaleElement();
  // Target pos inside scaled container
  // turned into shift difference
  const differ = (coord) => coord * (1 - scale / oldScale);
  diffShift(differ(elPos.x), differ(elPos.y));
}

function scaleElement() {
  scale = Math.max(0.1, Math.min(scale, 5));
  zoomTarget.style.transform = `scale(${scale})`;
}

The code above is a shortened version of:

const fp = focusedPoint;
const elPos = fp - rect;
const targetPos = (elPos - shift) / scale
// Rescale
scale += scaleChange
const diff = elPos - targetPos * scale - shift

It needs to be duplicated by putting x and y where and as needed (i.e. elPos, as above, should have x and y fields, not be a number). The diff is really elPos - scale * (elPos - shift) / oldScale - shift which then is (elPos - shift) - (elPos - shift) * scale / oldScale so we end up with (elPos - shift) * (1 - scale / oldScale).

The first thing we do is we calculate which pixel of the container the mouse occupies. Scale doesn't apply as the container is not scaled at all. Then we calculate which pixel of content we have the mouse at. We need a pixel of a non-scaled element so we get a real pixel in real image. Because of that, there's / scale. Then we calculate the position on the new scale. We have a shift untouched by scale, so we need to take the pixel we want in the old place, then scale it to the current scale. And we subtract shift so that we can move by difference instead of setting a shift value. Also simplifies our math in the end.

The scaleElement is pretty straightforward. It just takes scale and clips it to some reasonable values so the content won't be zoomed indefinitely.

Shifting content into place

Now we need to implement diffShift so our content is shifted in the correct position.

function diffShift(diffX, diffY) {
  shift.x += diffX;
  shift.y += diffY;
  const parentRect = container.getBoundingClientRect();
  const rect = zoomTarget.getBoundingClientRect();
  const clipShift = (coord, wh) =>
    (shift[coord] = Math.max(
      -rect[wh] * 0.75,
      Math.min(shift[coord], parentRect[wh] - 0.25 * rect[wh]),
    ));
  clipShift("x", "width");
  clipShift("y", "height");
  zoomTarget.style.left = shift.x + "px";
  zoomTarget.style.top = shift.y + "px";
}

Here we just change shift and update the content's top and left styles. The clipShift clips position of the element so that we can't move it away from view entirely but allows us to hide most of it away in the container's overflow.

Adding zoom buttons

On mobile, zooming using the wheel isn't possible. Or maybe their wheel broke. Or they don't even have wheels. Or maybe someone just prefers to press a button. For that, we can add buttons in our HTML:

<button onclick="zoomIn()">Zoom In</button>
<button onclick="zoomOut()">Zoom Out</button>

And quickly implement the ability to use them to control the zoom level:

function zoomIn() {
  zoomView(1)
}

function zoomOut() {
  zoomView(-1)
}

function zoomView(modifier) {
  const rect = container.getBoundingClientRect();
  const pos = {
    x: rect.left + rect.width / 2,
    y: rect.top + rect.height / 2,
  };
  zoomAndShift(pos, modifier * 0.5);
}

Since we already have a function to zoom in on a point, we will use it by saying that we want to zoom in on the center of the container.

Adding dragging functionality

Now we can zoom in on the point where we have a cursor in an element. It wouldn't be properly interactive without the ability to drag and move options. We will add it now.

window.onload = () => {
  // Previous code
  makeDragMove()
}

function makeDragMove() {
  let pos = { x: 0, y: 0 };
  let moving = false;
  const setPos = (ev) => {
    pos = { x: ev.clientX, y: ev.clientY };
  };
  const move = (ev) => {
    const oldPos = pos;
    setPos(ev);
    const xDiff = pos.x - oldPos.x;
    const yDiff = pos.y - oldPos.y;
    diffShift(xDiff, yDiff);
  };
  const moveStop = (ev) => {
    if (!moving) {
      return;
    }
    ev.preventDefault();
    moving = false;
    move(ev);
  };
  const moveStart = (ev) => {
    ev.preventDefault();
    moving = true;
    setPos(ev);
  };
  container.addEventListener("contextmenu", moveStart);
  container.addEventListener("pointerdown", moveStart);
  window.addEventListener("pointerup", moveStop);
  window.addEventListener("touchend", moveStop);
  window.addEventListener("pointermove", (ev) => {
    if (!moving) {
      return;
    }
    ev.preventDefault();
    move(ev);
  });
}

We bind pointerdown and contextmenu events to start detecting moves in the element. These are bound to container because we want to detect clicks inside the interactive element to start moving it. The contextmenu is here so that on mobile devices we don't get a popup when holding a finger on the element. It has a bit of a downside where, on computer, pressing the right mouse button allows you to drag the window around, but that's a sacrifice I'm okay with.

The pointerup and touchend detect the end of the moving of the element. They are attached to window and so is pointermove, because when moving the content, we may move the mouse away from the element entirely, but we still need to detect that we stopped moving around in such a case.

Other than this, we just take the position of the mouse in the viewport when we start moving, update it when we move, and call diffShift with the difference between the previous and new position. We already implemented diffShift earlier, so no need to add any new shifting logic.

Conclusion

With all this, we should now be able to freely zoom and drag around an image or a map around. This is only the base part of the feature. You may need more logic if you want to be able to click on the element inside container, but detect it only if you weren't dragging around. I will leave figuring it out to you, but in the end it just comes around to reporting how far the content was moved before the click event got detected.

Related projects