1Building an Interactive UI Component
▶
Building an interactive UI component with drag-and-drop functionality is one of the most rewarding exercises in front-end development. It requires you to coordinate HTML structure, the browser's native drag-and-drop API, JavaScript event handling, data-model management, and accessibility best practices — all working together to create a seamless experience. A classic example is a Kanban board, where cards representing tasks can be dragged between columns ("To Do", "In Progress", "Done"), or a sortable list where items can be reordered by dragging. Throughout this topic, we will build toward exactly that kind of component, explaining every decision in depth so you understand not just what to do but why.
The HTML Drag and Drop API is built into modern browsers and exposes a set of events (dragstart, drag, dragover, dragleave, drop, dragend) along with a DataTransfer object that carries information about what is being dragged. Understanding the lifecycle of a single drag gesture — from the moment the user presses the mouse button on an element to the moment they release it over a target — is the foundation for everything else in this topic.
Planning the Interactive UI Component
Good planning prevents a tangle of event listeners and broken state updates later. Before writing a single line of JavaScript, you should be able to answer three questions clearly: Which elements are draggable? Where are they allowed to be dropped? What should happen at each stage of the interaction?
Start by identifying the draggable elements and their valid drop targets. In a Kanban board, the draggable elements are the task cards, and the valid drop targets are the column containers. In a sortable list, every list item is draggable and every gap between items (or the list itself) is a valid drop target. Writing this down explicitly — even just in a comment at the top of your JavaScript file — saves significant debugging time.
Next, sketch the HTML structure on paper or in a design tool before touching the editor. A Kanban board might look like this conceptually:
- A
<div class="board">wrapping the whole component - Three
<div class="column">containers, one per status - Inside each column, a
<div class="card">for each task
Sketching forces you to decide questions like: does the drop target include the column heading, or just the card list area inside it? Should an item dropped onto another item insert before or after it? Getting these answers before coding means your event listeners have a clear contract to implement.
Finally, define the expected user interaction flow step by step:
- User presses the mouse button on a card →
dragstartfires; the card should appear slightly transparent to show it is "picked up". - User drags over a column →
dragoverfires repeatedly; the column should highlight to signal it is a valid drop zone. - User moves out of a column without dropping →
dragleavefires; the column highlight is removed. - User releases the card over a column →
dropfires; the card is physically moved into the column in the DOM and the data model is updated. - Drag ends (whether dropped or cancelled) →
dragendfires; any remaining visual feedback classes are cleaned up.
Setting Up the HTML Structure
With the plan in hand, translate it into HTML. The most important attribute at this stage is draggable="true". Without it, the browser will not initiate a drag gesture on the element regardless of any JavaScript you attach.
<div class="board">
<div class="column" id="col-todo" aria-label="To Do">
<h2>To Do</h2>
<div class="card-list">
<div class="card" draggable="true" id="card-1" data-card-id="1">Write tests</div>
<div class="card" draggable="true" id="card-2" data-card-id="2">Update docs</div>
</div>
</div>
<div class="column" id="col-inprogress" aria-label="In Progress">
<h2>In Progress</h2>
<div class="card-list">
<div class="card" draggable="true" id="card-3" data-card-id="3">Build login page</div>
</div>
</div>
<div class="column" id="col-done" aria-label="Done">
<h2>Done</h2>
<div class="card-list"></div>
</div>
</div>
Notice several deliberate choices here. Each .card has both an id and a data-card-id attribute. The id gives us a fast DOM lookup handle; the data-card-id is what we will pass through the DataTransfer object, because it is the stable identifier that connects the DOM element to the data model. Using a data-* attribute instead of passing the raw id string means you can change your ID naming convention without breaking the data layer.
The drop targets are the .card-list divs inside each column, not the column headings. This is an important structural decision: attaching dragover and drop listeners to the .card-list means dropping on the heading does nothing, which matches most users' mental model of a Kanban board.
Implementing the dragstart Event
The dragstart event fires once, at the very beginning of a drag gesture. It is your only opportunity to load data into the DataTransfer object, which acts as a clipboard for the drag operation.
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('dragstart', handleDragStart);
card.addEventListener('dragend', handleDragEnd);
});
function handleDragStart(event) {
// Store the card's identifier so the drop handler can find the element later
event.dataTransfer.setData('text/plain', event.target.dataset.cardId);
// Store a reference to the element itself for same-page drops
// (Many implementations also set a module-level variable for convenience)
draggedCard = event.target;
// Give visual feedback: the card looks "lifted"
event.target.classList.add('dragging');
}
function handleDragEnd(event) {
// Always clean up, whether the drop succeeded or was cancelled
event.target.classList.remove('dragging');
draggedCard = null;
}
event.dataTransfer.setData('text/plain', value) stores a string under the MIME-type key 'text/plain'. This is the most universally compatible choice. You can store multiple pieces of data under different keys (e.g., a custom type like 'application/x-card-id'), but text/plain is always readable in the drop handler.
Storing a module-level variable (draggedCard) alongside setData is a common pattern for same-page drags. The DataTransfer data is protected during the drag — you cannot read it in dragover, only in drop — so the variable gives you access to the element itself during intermediate events if you need it. However, the DataTransfer approach is what enables cross-tab or cross-application drops if you ever need them.
The CSS class 'dragging' typically reduces the card's opacity and perhaps adds a slight border or shadow to indicate it has been "picked up":
.card.dragging {
opacity: 0.5;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: grabbing;
}
Handling the dragover Event on Drop Targets
The dragover event fires continuously — many times per second — while a draggable element is held over a potential drop target. By default, browsers do not permit dropping on any element. The way you opt in to receiving a drop is by calling event.preventDefault() inside a dragover handler. This is the single most common mistake beginners make: they write a drop handler but forget preventDefault() in dragover, and the drop never fires.
const cardLists = document.querySelectorAll('.card-list');
cardLists.forEach(list => {
list.addEventListener('dragover', handleDragOver);
list.addEventListener('dragleave', handleDragLeave);
list.addEventListener('drop', handleDrop);
});
function handleDragOver(event) {
event.preventDefault(); // Crucial: tells the browser this is a valid drop zone
// Set the visual cursor style (optional but informative)
event.dataTransfer.dropEffect = 'move';
// Highlight the target column to show it will accept the drop
event.currentTarget.classList.add('drag-over');
}
function handleDragLeave(event) {
// Remove highlight when drag moves away
// Use relatedTarget to avoid flickering when moving between child elements
if (!event.currentTarget.contains(event.relatedTarget)) {
event.currentTarget.classList.remove('drag-over');
}
}
The dragleave nuance is important. If the .card-list contains child elements (the cards themselves), the browser fires dragleave when the mouse moves over a child element and dragenter on that child — even though visually you are still inside the list. Using event.currentTarget.contains(event.relatedTarget) checks whether the element the pointer moved to is still inside the drop target. If it is, we are not truly leaving, so we keep the highlight.
The drag-over CSS class might look like this:
.card-list.drag-over {
background-color: #e8f4ff;
border: 2px dashed #4a90e2;
border-radius: 6px;
}
Implementing the drop Event to Reorder or Move Items
When the user releases the dragged element over a highlighted drop target, the drop event fires. Here is where the actual DOM manipulation happens.
function handleDrop(event) {
event.preventDefault(); // Prevent default browser behavior (e.g., opening a link)
// Retrieve the identifier stored during dragstart
const cardId = event.dataTransfer.getData('text/plain');
// Locate the actual DOM element using the retrieved identifier
const card = document.querySelector(`.card[data-card-id="${cardId}"]`);
// Remove highlight from the drop target
event.currentTarget.classList.remove('drag-over');
if (card) {
// Append the card to the new column's card list
event.currentTarget.appendChild(card);
// Update the data model (covered in the next section)
updateDataModel(cardId, event.currentTarget.closest('.column').id);
}
}
Using querySelector with a data-card-id attribute selector is robust: it works even if the element has been moved in the DOM since dragstart, because we are querying the live document. The appendChild call moves the element — it does not clone it. If the element already exists in the DOM (which it does, in its original column), calling appendChild on a different parent automatically removes it from the original parent. This is a built-in feature of the DOM that makes the move operation a single call.
For a sortable list where order within the same container matters, you would use insertBefore instead of appendChild. A typical approach is to find the card element immediately after the cursor position and insert before it:
function getDragAfterElement(container, y) {
// Get all non-dragging cards in this container
const draggableElements = [
...container.querySelectorAll('.card:not(.dragging)')
];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
// Find the element whose center is just below the cursor
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function handleDrop(event) {
event.preventDefault();
const cardId = event.dataTransfer.getData('text/plain');
const card = document.querySelector(`.card[data-card-id="${cardId}"]`);
event.currentTarget.classList.remove('drag-over');
if (card) {
const afterElement = getDragAfterElement(event.currentTarget, event.clientY);
if (afterElement == null) {
event.currentTarget.appendChild(card);
} else {
event.currentTarget.insertBefore(card, afterElement);
}
updateDataModel(cardId, event.currentTarget.closest('.column').id);
}
}
getBoundingClientRect() gives the pixel position of each card. By comparing the cursor's Y coordinate (event.clientY) to each card's midpoint, we find the correct insertion position, enabling precise reordering within the same column.
Managing State and Updating the Data Model
Moving elements in the DOM gives the user visual feedback, but the DOM is a poor place to store application state. The DOM can be modified by browser extensions, accessibility tools, or other scripts; it is slow to query for complex data needs; and it does not serialize easily for saving or syncing. Instead, maintain a JavaScript data model that mirrors the board state.
// Initial data model — mirrors the starting HTML
let boardState = {
'col-todo': ['1', '2'],
'col-inprogress': ['3'],
'col-done': []
};
function updateDataModel(cardId, newColumnId) {
// Remove the card from whichever column currently holds it
for (const columnId in boardState) {
boardState[columnId] = boardState[columnId].filter(id => id !== cardId);
}
// Find its new position in the DOM to insert at the correct index
const column = document.getElementById(newColumnId);
const cardList = column.querySelector('.card-list');
const domOrder = [...cardList.querySelectorAll('.card')].map(
el => el.dataset.cardId
);
// Replace the column's array with the current DOM order
boardState[newColumnId] = domOrder;
console.log('Board state updated:', JSON.stringify(boardState, null, 2));
// In a real app, you might persist this to localStorage or send it to a server
persistState();
}
function persistState() {
localStorage.setItem('kanbanBoard', JSON.stringify(boardState));
}
This approach keeps the data model and the DOM in sync: the DOM drives the visual, and immediately after each drop we read the new DOM order back into the model. An alternative is to update the model first and then re-render the DOM from the model (the "single source of truth" pattern used by React and Vue). For a vanilla JavaScript project the DOM-first approach shown above is simpler and equally correct.
On page load, you can restore saved state from localStorage:
function loadState() {
const saved = localStorage.getItem('kanbanBoard');
if (saved) {
boardState = JSON.parse(saved);
renderBoard();
}
}
function renderBoard() {
for (const columnId in boardState) {
const cardList = document.querySelector(`#${columnId} .card-list`);
cardList.innerHTML = ''; // Clear existing cards
boardState[columnId].forEach(cardId => {
// Find the card element from a detached pool or create it fresh
const card = createCardElement(cardId);
cardList.appendChild(card);
});
}
}
loadState();
This round-trip — save to model on drop, restore from model on load — gives your component persistence without a backend.
Accessibility and Usability Enhancements
A drag-and-drop interface built entirely on mouse events is inaccessible to users who navigate by keyboard, use screen readers, or have motor impairments that make precise dragging impossible. Accessibility is not an optional polish step; it is part of the component's definition of "done".
ARIA attributes communicate drag state to assistive technologies. The relevant attributes are:
| Attribute | Applied To | Purpose |
|---|---|---|
aria-grabbed="false" |
Draggable elements (at rest) | Announces the element can be grabbed |
aria-grabbed="true" |
The element currently being dragged | Announces the element has been picked up |
aria-dropeffect="move" |
Valid drop target containers | Tells screen readers what action dropping will perform |
aria-dropeffect="none" |
Containers that are not valid targets | Signals the container does not accept this drag |
role="listitem" |
Each draggable card | Provides semantic context within a role="list" container |
Update these attributes in your event handlers:
function handleDragStart(event) {
event.dataTransfer.setData('text/plain', event.target.dataset.cardId);
draggedCard = event.target;
event.target.classList.add('dragging');
event.target.setAttribute('aria-grabbed', 'true');
// Mark all valid drop targets
document.querySelectorAll('.card-list').forEach(list => {
list.setAttribute('aria-dropeffect', 'move');
});
}
function handleDragEnd(event) {
event.target.classList.remove('dragging');
event.target.setAttribute('aria-grabbed', 'false');
draggedCard = null;
// Reset all drop targets
document.querySelectorAll('.card-list').forEach(list => {
list.setAttribute('aria-dropeffect', 'none');
list.classList.remove('drag-over');
});
}
Keyboard interaction is the most important accessibility enhancement. A user should be able to select a card, then use keyboard commands to move it. A clean pattern:
- Space or Enter on a focused card: "picks up" the card (enters move mode).
- Arrow keys: move the card up/down within the current column, or left/right between columns.
- Space or Enter again: "drops" the card in its current position, confirming the move.
- Escape: cancels the operation and returns the card to its original position.
let selectedCard = null;
let originalPosition = null; // { parentNode, nextSibling }
cards.forEach(card => {
card.setAttribute('tabindex', '0'); // Make keyboard focusable
card.addEventListener('keydown', (event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
if (!selectedCard) {
// Pick up
selectedCard = card;
originalPosition = {
parentNode: card.parentNode,
nextSibling: card.nextSibling
};
card.classList.add('dragging');
card.setAttribute('aria-grabbed', 'true');
announceToScreenReader(`${card.textContent} picked up. Use arrow keys to move.`);
} else {
// Drop
selectedCard.classList.remove('dragging');
selectedCard.setAttribute('aria-grabbed', 'false');
updateDataModel(selectedCard.dataset.cardId,
selectedCard.closest('.column').id);
announceToScreenReader(`${selectedCard.textContent} dropped.`);
selectedCard = null;
}
}
if (selectedCard && event.key === 'ArrowDown') {
event.preventDefault();
const next = selectedCard.nextElementSibling;
if (next) {
selectedCard.parentNode.insertBefore(next, selectedCard);
selectedCard.focus();
}
}
if (selectedCard && event.key === 'ArrowUp') {
event.preventDefault();
const prev = selectedCard.previousElementSibling;
if (prev) {
selectedCard.parentNode.insertBefore(selectedCard, prev);
selectedCard.focus();
}
}
if (event.key === 'Escape' && selectedCard) {
// Return to original position
originalPosition.parentNode.insertBefore(selectedCard,
originalPosition.nextSibling);
selectedCard.classList.remove('dragging');
selectedCard.setAttribute('aria-grabbed', 'false');
announceToScreenReader(`Move cancelled.`);
selectedCard = null;
}
});
});
// A live region for screen reader announcements
function announceToScreenReader(message) {
let region = document.getElementById('sr-announcer');
if (!region) {
region = document.createElement('div');
region.id = 'sr-announcer';
region.setAttribute('aria-live', 'assertive');
region.setAttribute('aria-atomic', 'true');
region.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;';
document.body.appendChild(region);
}
region.textContent = '';
// Small timeout so the change is noticed by screen readers
setTimeout(() => { region.textContent = message; }, 50);
}
The aria-live="assertive" region causes the screen reader to immediately interrupt and read the announcement, which is appropriate for action feedback. Use "polite" for non-urgent updates like status changes that can wait for the reader to finish its current sentence.
Focus management ensures that after a card is moved, the keyboard user's focus stays on the card, not lost to the body or some other element. Calling selectedCard.focus() after every DOM insertion — as shown above in the arrow-key handlers — achieves this. Additionally, ensure your CSS provides a clear focus indicator:
.card:focus {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
Never use outline: none on interactive elements without providing an equally visible custom focus style. Many users with low vision rely on the focus ring to know where they are on the page.
Putting it all together, a complete and accessible drag-and-drop component requires: a clear HTML structure with draggable="true" and meaningful data-* attributes; dragstart/dragend handlers for the drag lifecycle; dragover with preventDefault() and dragleave for drop-target feedback; a drop handler that retrieves the identifier, moves the DOM element, and updates the data model; a JavaScript state object kept in sync with the DOM after every operation; ARIA attributes updated in lockstep with visual state changes; a keyboard interaction path that mirrors every mouse-based operation; and an aria-live region that narrates each significant action to screen reader users. With all these pieces in place, your component is not just interactive — it is inclusive, maintainable, and production-ready.