Draggable elements in JavaScript without external libraries

April 10, 2021

When making draggable elements, we tend to use big, complex external libraries. But is this really necessary if we don’t need any fancy capabilities? Are there any simple and lightweight solutions to our problem? Let’s try to implement basic dragging feature in Vanilla JS.

Goal

Our goal is to create element that user can freely drag. This is how final result will look like:

Prototype

Draggable element

First, let’s create an element we are going to make draggable. In this element, we are going to add a “handle” we can draw with, but we will also add an option to drag using whole element, when handle is not defined.

<div class="draggable-item">
  <div class="draggable-handle">Handle</div>
  <div>
    Lorem, ipsum dolor sit amet consectetur adipisicing elit. Quam quasi labore
    possimus, amet aspernatur cum rerum asperiores dolor incidunt! Eligendi
    saepe odit ratione itaque pariatur eveniet amet voluptatibus cum nulla!
  </div>
</div>

Also, let’s add some example styles to it (only for testing purposes)

.draggable-item {
  width: 200px;
  text-align: center;
  background-color: white;
  border: 1px solid gray;
  user-select: none;
}

.draggable-handle {
  padding: 20px;
  cursor: move;
  background-color: lightgray;
}

We should now have something like this:

10

Draggable function

Now, when we have element we are going to make draggable, let’s make a function to do this. I am going to call it simpleDraggable and it will take two arguments - required element selector and optional handle selector. Create also three functions that will take care of draggable behaviour - one called when element is clicked, second when dragged, and third when released. Also add console.logs to see if functions are called correctly.

function simpleDraggable(elementSelector, handleSelector) {
  const start = () => {
    console.log('start');
  };

  const dragging = () => {
    console.log('dragging');
  };

  const stop = () => {
    console.log('stop');
  };
}

We need to find element specified by provided selection. To do so, we are going to use querySelector method.

const element = document.querySelector(elementSelector);

Let’s also throw an error when element is not found.

if (!element) {
  throw Error(`Element with selector ${elementSelector} does not exist`);
}

We also need to get handler element, but it’s optional, so we are not going to throw an error if it’s not found.

let handle;
if (handleSelector) {
  handle = element.querySelector(handleSelector);
}

Our code now should look like this:

function simpleDraggable(elementSelector, handleSelector) {
  const element = document.querySelector(elementSelector);
  if (!element) {
    throw Error(`Element with selector ${elementSelector} does not exist`);
  }

  let handle;
  if (handleSelector) {
    handle = element.querySelector(handleSelector);
  }

  const start = () => {
    console.log('start');
  };

  const dragging = () => {
    console.log('dragging');
  };

  const stop = () => {
    console.log('stop');
  };
}

Now, we should add some handlers to call these functions at the right moment. Events we are going to listen to are mousedown, mouseup and mousemove.

To listen to clicking on our element or on a handle, let’s use mousedown event. If handleSelector is provided and handle element exists, register event listener on a handle. If not, register it on whole element.

if (handleSelector && handle) {
  handle.addEventListener('mousedown', start);
} else {
  element.addEventListener('mousedown', start);
}

And now, call our function providing correct selectors to our elements.

simpleDraggable('.draggable-item', '.draggable-handle');

Whole script looks like this

function simpleDraggable(elementSelector, handleSelector) {
  const element = document.querySelector(elementSelector);
  if (!element) {
    throw Error(`Element with selector ${elementSelector} does not exist`);
  }

  let handle;
  if (handleSelector) {
    handle = element.querySelector(handleSelector);
  }

  const start = () => {
    console.log('start');
  };

  const dragging = () => {
    console.log('dragging');
  };

  const stop = () => {
    console.log('stop');
  };

  if (handleSelector && handle) {
    handle.addEventListener('mousedown', start);
  } else {
    element.addEventListener('mousedown', start);
  }
}

simpleDraggable('.draggable-item', '.draggable-handle');

We should have start function working fine, when element is clicked.

After element was clicked, we need to register dragging behaviour. To do this, let’s add in our start function event listener for mousemove. One thing to keep in mind there is that we need to register this listener on a document, not an element. It’s because when moving our mouse, if we move cursor fast enough, we will move it outside our element - and it will break our mechanism. We need to listen to cursor movement on whole document then.

document.addEventListener('mousemove', dragging);

We also need to add listener to mouseup, where we will handle end of dragging process.

document.addEventListener('mouseup', stop);

And let’s remove these listeners in stop function

document.removeEventListener('mouseup', stop);
document.removeEventListener('mousemove', dragging);

Our code should now look like this:

function simpleDraggable(elementSelector, handleSelector) {
  const element = document.querySelector(elementSelector);
  if (!element) {
    throw Error(`Element with selector ${elementSelector} does not exist`);
  }

  let handle;
  if (handleSelector) {
    handle = element.querySelector(handleSelector);
  }

  const start = () => {
    console.log('start');

    document.addEventListener('mousemove', dragging);
    document.addEventListener('mouseup', stop);
  };

  const dragging = () => {
    console.log('dragging');
  };

  const stop = () => {
    console.log('stop');

    document.removeEventListener('mousemove', dragging);
    document.removeEventListener('mouseup', stop);
  };

  if (handleSelector && handle) {
    handle.addEventListener('mousedown', start);
  } else {
    element.addEventListener('mousedown', start);
  }
}

simpleDraggable('.draggable-item', '.draggable-handle');

And now, when trying to drag our element, we should be able to see that everything is being called correctly.

Dragging behaviour

We have our base created, it’s time to implement dragging behaviour itself. To be able to drag element, we need to make sure it’s absolutely positioned. To do so, add

element.style.position = 'absolute';

How we are going to drag our element? Process will look like this:

  1. On click, get cursor position
  2. When dragging, calculate cursor position change
  3. Update variables to store new cursor position
  4. Update element position according to position change

To do this, we need a way to store cursor position. Let’s add in our function variables cursorPositionX and cursorPositionY.

let cursorPositionX = 0;
let cursorPositionY = 0;

To be able to get initial cursor position and update it during dragging, let’s pass event variable to start and dragging functions, so we can use clientX and clientY properties of MouseEvent event instance

const start = (e) => {
  // ...
};

const dragging = (e) => {
  // ...
};

And now, we can save cursor positions in start and dragging functions using

cursorPositionX = e.clientX;
cursorPositionY = e.clientY;

They should now look like this

const start = (e) => {
  console.log('start');

  // get initial cursor position
  cursorPositionX = e.clientX;
  cursorPositionY = e.clientY;

  document.addEventListener('mousemove', dragging);
  document.addEventListener('mouseup', stop);
};

const dragging = (e) => {
  console.log('dragging');

  // save new cursor position
  cursorPositionX = e.clientX;
  cursorPositionY = e.clientY;
};

But before saving new cursor position, we should calculate position change.

const dragging = (e) => {
  console.log('dragging');

  // calculate position change
  const positionChangeX = cursorPositionX - e.clientX;
  const positionChangeY = cursorPositionY - e.clientY;

  // save new cursor position
  cursorPositionX = e.clientX;
  cursorPositionY = e.clientY;
};

And the last thing to do is to move element using computed position change. To do this, we are going to use offsetLeft and offsetTop properties of our element. Final dragging function should look like this

const dragging = (e) => {
  console.log('dragging');

  // calculate position change
  const positionChangeX = cursorPositionX - e.clientX;
  const positionChangeY = cursorPositionY - e.clientY;

  // save new cursor position
  cursorPositionX = e.clientX;
  cursorPositionY = e.clientY;

  // set the element's new position:
  element.style.left = `${element.offsetLeft - positionChangeX}px`;
  element.style.top = `${element.offsetTop - positionChangeY}px`;
};

And whole script

function simpleDraggable(elementSelector, handleSelector) {
  const element = document.querySelector(elementSelector);
  if (!element) {
    throw Error(`Element with selector ${elementSelector} does not exist`);
  }

  let handle;
  if (handleSelector) {
    handle = element.querySelector(handleSelector);
  }

  element.style.position = 'absolute';

  let cursorPositionX = 0;
  let cursorPositionY = 0;

  const start = (e) => {
    console.log('start');

    // get initial cursor position
    cursorPositionX = e.clientX;
    cursorPositionY = e.clientY;

    document.addEventListener('mousemove', dragging);
    document.addEventListener('mouseup', stop);
  };

  const dragging = (e) => {
    console.log('dragging');

    // calculate position change
    const positionChangeX = cursorPositionX - e.clientX;
    const positionChangeY = cursorPositionY - e.clientY;

    // save new cursor position
    cursorPositionX = e.clientX;
    cursorPositionY = e.clientY;

    // set the element's new position:
    element.style.left = `${element.offsetLeft - positionChangeX}px`;
    element.style.top = `${element.offsetTop - positionChangeY}px`;
  };

  const stop = () => {
    console.log('stop');

    document.removeEventListener('mousemove', dragging);
    document.removeEventListener('mouseup', stop);
  };

  if (handleSelector && handle) {
    handle.addEventListener('mousedown', start);
  } else {
    element.addEventListener('mousedown', start);
  }
}

simpleDraggable('.draggable-item', '.draggable-handle');

Final result:

Now we can remove unnecessary console.logs and our script is ready to be used.

Improvements

Handling multiple elements

To be useful, this mechanism needs to handle multiple draggable elements. Fortunately, this is easily to implement. First, let’s create second draggable element

<div class="draggable-item">
  <div class="draggable-handle">Handle</div>
  <div>
    First draggable element. Lorem, ipsum dolor sit amet consectetur adipisicing
    elit. Quam quasi labore possimus, amet aspernatur cum rerum asperiores dolor
    incidunt! Eligendi saepe odit ratione itaque pariatur eveniet amet
    voluptatibus cum nulla!
  </div>
</div>

<div class="draggable-item">
  <div class="draggable-handle">Handle</div>
  <div>
    Second draggable element. Lorem, ipsum dolor sit amet consectetur
    adipisicing elit. Quam quasi labore possimus, amet aspernatur cum rerum
    asperiores dolor incidunt! Eligendi saepe odit ratione itaque pariatur
    eveniet amet voluptatibus cum nulla!
  </div>
</div>

As you can see, only first element is registered as a draggable. It’s because we use querySelector that returns first element matching specified selector. To find all matching elements, let’s use querySelectorAll.

const elements = document.querySelectorAll(elementSelector);
if (elements.length === 0) {
  throw Error(`Elements matching selector ${elementSelector} not found`);
}

Next, let’s just wrap rest of mechanism into for...of loop

function simpleDraggable(elementSelector, handleSelector) {
  const elements = document.querySelectorAll(elementSelector);
  if (elements.length === 0) {
    throw Error(`Elements matching selector ${elementSelector} not found`);
  }

  for (let element of elements) {
    let handle;
    if (handleSelector) {
      handle = element.querySelector(handleSelector);
    }

    element.style.position = 'absolute';

    let cursorPositionX = 0;
    let cursorPositionY = 0;

    const start = (e) => {
      // get initial cursor position
      cursorPositionX = e.clientX;
      cursorPositionY = e.clientY;

      document.addEventListener('mousemove', dragging);
      document.addEventListener('mouseup', stop);
    };

    const dragging = (e) => {
      // calculate position change
      const positionChangeX = cursorPositionX - e.clientX;
      const positionChangeY = cursorPositionY - e.clientY;

      // save new cursor position
      cursorPositionX = e.clientX;
      cursorPositionY = e.clientY;

      // set the element's new position:
      element.style.left = `${element.offsetLeft - positionChangeX}px`;
      element.style.top = `${element.offsetTop - positionChangeY}px`;
    };

    const stop = () => {
      document.removeEventListener('mousemove', dragging);
      document.removeEventListener('mouseup', stop);
    };

    if (handleSelector && handle) {
      handle.addEventListener('mousedown', start);
    } else {
      element.addEventListener('mousedown', start);
    }
  }
}

simpleDraggable('.draggable-item', '.draggable-handle');

Mobile support

Our mechanism in current form works only on desktop, because we handle only desktop events. Now, if we try to use it on mobile, page scrolls but elements stay in place.

We need to handle touch events:

  • touchstart
  • touchmove
  • touchend and touchcancel

Let’s add event listeners then

document.addEventListener('mousemove', dragging);
document.addEventListener('touchmove', dragging);

document.addEventListener('mouseup', stop);
document.addEventListener('touchend', stop);
document.addEventListener('touchcancel', stop);

// ...

document.removeEventListener('mousemove', dragging);
document.removeEventListener('touchmove', dragging);

document.removeEventListener('mouseup', stop);
document.removeEventListener('touchend', stop);
document.removeEventListener('touchcancel', stop);

// ...

if (handleSelector && handle) {
  handle.addEventListener('mousedown', start);
  handle.addEventListener('touchstart', start);
} else {
  element.addEventListener('mousedown', start);
  element.addEventListener('touchstart', start);
}

Another thing we need to keep in mind is that we no longer receive only MouseEvent, but also TouchEvent. TouchEvent has different structure than MouseEvent, so we need to differentiate how we get clientX and clientY values.

To get them in TouchEvent we will use touches property that keeps information about all current touches. In our case, we only care about one “finger”, so we are going to read only first touch event. Let’s create simple utility function that will help us with getting clientX and clientY values for both cases

const getCursorPositionFromEvent = (e) => {
  if (e.touches && e.touches.length > 0) {
    return {
      x: e.touches[0].clientX,
      y: e.touches[0].clientY,
    };
  } else {
    return {
      x: e.clientX,
      y: e.clientY,
    };
  }
};

And use this function in start and dragging

const cursorPosition = getCursorPositionFromEvent(e);
cursorPositionX = cursorPosition.x;
cursorPositionY = cursorPosition.y;

// ...

const cursorPosition = getCursorPositionFromEvent(e);

const positionChangeX = cursorPositionX - cursorPosition.x;
const positionChangeY = cursorPositionY - cursorPosition.y;

cursorPositionX = cursorPosition.x;
cursorPositionY = cursorPosition.y;

And the last thing to do is to use preventDefault to prevent scrolling on mobile when element is being dragged. Add it to start and dragging functions.

We want also to be able to drag multiple elements together. Good news - in our case, we don’t need to change anything - reading only first touch position changes will affect every touched elements. There is also a possibility to handle every touch separately, so we are able to touch multiple elements at once, but move them separately in different directions. This approach can be achieved by iterating though all current events, determining which event belongs to target element and reading its position. In that tutorial we will stick to the first approach, but in the future I will create another post with detailed explanation on how to do this.

Script should look like this

function simpleDraggable(elementSelector, handleSelector) {
  const elements = document.querySelectorAll(elementSelector);
  if (elements.length === 0) {
    throw Error(`Elements matching selector ${elementSelector} not found`);
  }

  const getCursorPositionFromEvent = (e) => {
    if (e.touches && e.touches.length > 0) {
      return {
        x: e.touches[0].clientX,
        y: e.touches[0].clientY,
      };
    } else {
      return {
        x: e.clientX,
        y: e.clientY,
      };
    }
  };

  for (let element of elements) {
    let handle;
    if (handleSelector) {
      handle = element.querySelector(handleSelector);
    }

    element.style.position = 'absolute';

    let cursorPositionX = 0;
    let cursorPositionY = 0;

    const start = (e) => {
      e.preventDefault();

      // get initial cursor position
      const cursorPosition = getCursorPositionFromEvent(e);
      cursorPositionX = cursorPosition.x;
      cursorPositionY = cursorPosition.y;

      document.addEventListener('mousemove', dragging);
      document.addEventListener('touchmove', dragging);

      document.addEventListener('mouseup', stop);
      document.addEventListener('touchend', stop);
      document.addEventListener('touchcancel', stop);
    };

    const dragging = (e) => {
      e.preventDefault();

      // get new cursor position
      const cursorPosition = getCursorPositionFromEvent(e);

      // calculate position change
      const positionChangeX = cursorPositionX - cursorPosition.x;
      const positionChangeY = cursorPositionY - cursorPosition.y;

      // save new cursor position
      cursorPositionX = cursorPosition.x;
      cursorPositionY = cursorPosition.y;

      // set the element's new position:
      element.style.left = `${element.offsetLeft - positionChangeX}px`;
      element.style.top = `${element.offsetTop - positionChangeY}px`;
    };

    const stop = () => {
      document.removeEventListener('mousemove', dragging);
      document.removeEventListener('touchmove', dragging);

      document.removeEventListener('mouseup', stop);
      document.removeEventListener('touchend', stop);
      document.removeEventListener('touchcancel', stop);
    };

    if (handleSelector && handle) {
      handle.addEventListener('mousedown', start);
      handle.addEventListener('touchstart', start);
    } else {
      element.addEventListener('mousedown', start);
      element.addEventListener('touchstart', start);
    }
  }
}

simpleDraggable('.draggable-item', '.draggable-handle');

Let’s test final result on mobile

And dragging multiple elements at once

Summary

If we need only basic dragging functionality, this is a great way to implement such. In the future, I plan to create follow-up post with more improvements to this mechanism. Besides that, I will create super-lightweight, zero dependency library with mechanism we just created.