Draggable elements in JavaScript without external libraries
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:
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.log
s 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:
- On click, get cursor position
- When dragging, calculate cursor position change
- Update variables to store new cursor position
- 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.log
s 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
andtouchcancel
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.