칸반 보드 같은 걸 만들 때 드래그 앤 드롭은 필수다. 라이브러리를 쓰면 편하긴 한데, 간단한 기능이면 직접 구현하는 게 번들 사이즈도 줄고 커스터마이징도 자유롭다. HTML5 Drag and Drop API가 있긴 하지만 모바일에서 제대로 작동하지 않아서, 마우스/터치 이벤트를 직접 다루는 방식으로 구현했다.
HTML 구조
<div class="container">
<div class="column" id="todo">
<h2>할 일</h2>
<div class="card" draggable="true">카드 1</div>
<div class="card" draggable="true">카드 2</div>
</div>
<div class="column" id="doing">
<h2>진행 중</h2>
<div class="card" draggable="true">카드 3</div>
</div>
<div class="column" id="done">
<h2>완료</h2>
</div>
</div>
CSS
드래그 중인 요소에 시각적 피드백을 주는 게 중요하다:
.container {
display: flex;
gap: 1rem;
padding: 1rem;
}
.column {
flex: 1;
min-height: 300px;
background: #f0f0f0;
border-radius: 8px;
padding: 1rem;
}
.card {
background: white;
padding: 1rem;
margin-bottom: 0.5rem;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
cursor: grab;
user-select: none;
transition: transform 0.15s, box-shadow 0.15s;
}
.card.dragging {
opacity: 0.5;
transform: rotate(3deg);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.column.over {
background: #e0e7ff;
}
마우스 이벤트로 구현
기본적인 드래그 로직이다:
let draggedCard = null;
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('mousedown', startDrag);
});
function startDrag(e) {
draggedCard = e.target;
draggedCard.classList.add('dragging');
const shiftX = e.clientX - draggedCard.getBoundingClientRect().left;
const shiftY = e.clientY - draggedCard.getBoundingClientRect().top;
// 복제본을 만들어서 커서 따라다니게
const clone = draggedCard.cloneNode(true);
clone.style.position = 'fixed';
clone.style.zIndex = '1000';
clone.style.pointerEvents = 'none';
clone.style.width = draggedCard.offsetWidth + 'px';
document.body.appendChild(clone);
function moveAt(clientX, clientY) {
clone.style.left = clientX - shiftX + 'px';
clone.style.top = clientY - shiftY + 'px';
}
moveAt(e.clientX, e.clientY);
function onMouseMove(e) {
moveAt(e.clientX, e.clientY);
highlightDropZone(e.clientX, e.clientY);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', function onMouseUp(e) {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
clone.remove();
draggedCard.classList.remove('dragging');
const dropTarget = getDropTarget(e.clientX, e.clientY);
if (dropTarget && dropTarget !== draggedCard.parentElement) {
dropTarget.appendChild(draggedCard);
}
clearHighlights();
draggedCard = null;
}, { once: true });
}
드롭 대상을 찾는 함수:
function getDropTarget(x, y) {
const columns = document.querySelectorAll('.column');
for (const col of columns) {
const rect = col.getBoundingClientRect();
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
return col;
}
}
return null;
}
function highlightDropZone(x, y) {
clearHighlights();
const target = getDropTarget(x, y);
if (target) target.classList.add('over');
}
function clearHighlights() {
document.querySelectorAll('.column').forEach(c => c.classList.remove('over'));
}
모바일 터치 이벤트 대응
모바일에서는 mousedown 대신 touchstart를 써야 한다. 이벤트 구조가 살짝 다르다:
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('touchstart', startTouchDrag, { passive: false });
});
function startTouchDrag(e) {
e.preventDefault(); // 스크롤 방지
const touch = e.touches[0];
draggedCard = e.target;
draggedCard.classList.add('dragging');
const shiftX = touch.clientX - draggedCard.getBoundingClientRect().left;
const shiftY = touch.clientY - draggedCard.getBoundingClientRect().top;
const clone = draggedCard.cloneNode(true);
clone.style.position = 'fixed';
clone.style.zIndex = '1000';
clone.style.pointerEvents = 'none';
clone.style.width = draggedCard.offsetWidth + 'px';
document.body.appendChild(clone);
function onTouchMove(e) {
const touch = e.touches[0];
clone.style.left = touch.clientX - shiftX + 'px';
clone.style.top = touch.clientY - shiftY + 'px';
highlightDropZone(touch.clientX, touch.clientY);
}
function onTouchEnd(e) {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
clone.remove();
draggedCard.classList.remove('dragging');
const touch = e.changedTouches[0];
const dropTarget = getDropTarget(touch.clientX, touch.clientY);
if (dropTarget && dropTarget !== draggedCard.parentElement) {
dropTarget.appendChild(draggedCard);
}
clearHighlights();
draggedCard = null;
}
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}
핵심은 e.preventDefault()다. 이걸 안 하면 드래그 대신 페이지 스크롤이 된다. { passive: false } 옵션도 빼먹으면 안 된다.
정렬 기능 추가
같은 컬럼 안에서 카드 순서를 바꾸고 싶다면, 드롭 위치를 기준으로 카드 앞뒤를 판단해야 한다:
function getInsertPosition(column, y) {
const cards = [...column.querySelectorAll('.card:not(.dragging)')];
for (const card of cards) {
const rect = card.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (y < midY) return card;
}
return null; // 맨 뒤에 추가
}
드롭할 때 appendChild 대신 insertBefore를 쓰면 된다:
const beforeCard = getInsertPosition(dropTarget, clientY);
if (beforeCard) {
dropTarget.insertBefore(draggedCard, beforeCard);
} else {
dropTarget.appendChild(draggedCard);
}
라이브러리 없이도 충분히 쓸만한 드래그 앤 드롭을 만들 수 있다. 코드가 좀 길어 보이지만 원리를 이해하면 커스터마이징이 자유로워서 오히려 편하다.