Drag and Drop ลากและวาง
A Kanban board built with Pointer Events (mouse, pen and touch); the new order is saved in one transaction.
Live demo
Everything here talks to the real Spring Boot + SQL Server back end.Drag a card with the mouse or a finger. Keyboard: focus a card and press Alt + ← → (column) or ↑ ↓ (order).
To do
4
Write the user guide
Docs
Design empty states
Design
Add keyboard help
Feature
Check colour contrast
Design
In progress
3
Dark mode tokens
Design
Toast queue
Feature
Fix tooltip flip
Bug
Done
2
Create Flyway schema
Backend
Seed sample data
Backend
Saved positions come from task_card.status and task_card.position.
What to notice
- Pointer Events work on touch screens, unlike the HTML5 drag API.
- A placeholder shows where the card will land.
- Keyboard users can move a focused card with Alt + arrows.
Try it
Source code
8 parts, read from the files in this project
src/main/java/com/study/uiux/web/BoardApi.java
PUT /api/tasks/board
@PutMapping("/tasks/board")
public Map<String, List<TaskCard>> saveBoard(@Valid @RequestBody BoardUpdate update) {
return board.save(update.columns());
}
src/main/java/com/study/uiux/service/TaskBoardService.java
Save the whole board after a drop, in one transaction
@Transactional
public Map<String, List<TaskCard>> save(Map<String, List<Integer>> columns) {
Map<Integer, TaskCard> cards = repository.findAll().stream().collect(Collectors.toMap(TaskCard::getId, Function.identity()));
Set<Integer> seen = new HashSet<>();
for (Map.Entry<String, List<Integer>> column : columns.entrySet()) {
if (!COLUMNS.contains(column.getKey())) throw ApiException.badRequest("Unknown column " + column.getKey());
List<Integer> ids = column.getValue();
for (int position = 0; position < ids.size(); position++) {
TaskCard card = cards.get(ids.get(position));
if (card == null || !seen.add(card.getId())) throw ApiException.badRequest("The board changed. Reload the page.");
card.setStatus(column.getKey()); // only rows that really changed
card.setPosition(position); // get an UPDATE (dirty checking)
}
}
if (seen.size() != cards.size()) throw ApiException.badRequest("The board changed. Reload the page.");
return board();
}
src/main/resources/db/migration/V1__schema.sql
task_card: status + position are saved after every drop
CREATE TABLE task_card (
id INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_task_card PRIMARY KEY,
title NVARCHAR(120) NOT NULL,
status NVARCHAR(10) NOT NULL,
position INT NOT NULL,
label NVARCHAR(20) NOT NULL,
updated_at DATETIME2(0) NOT NULL CONSTRAINT df_task_updated DEFAULT SYSUTCDATETIME(),
CONSTRAINT ck_task_status CHECK (status IN (N'TODO', N'DOING', N'DONE'))
);
CREATE INDEX ix_task_board ON task_card (status, position);
src/main/resources/templates/demos/l327.html
Board markup: one column per status, cards in position order
<div class="board" id="board">
<section class="column" th:each="col : ${board}" th:attr="data-status=${col.key}" th:aria-label="${col.key}">
<div class="column-head">
<span class="dot" th:style="'background:' + ${col.key == 'TODO' ? 'var(--text-3)' : (col.key == 'DOING' ? 'var(--warning)' : 'var(--success)')}"></span>
<span th:text="${col.key == 'TODO' ? 'To do' : (col.key == 'DOING' ? 'In progress' : 'Done')}">To do</span>
<span class="count" th:text="${#lists.size(col.value)}">0</span>
</div>
<div class="column-body">
<div class="task" th:each="t : ${col.value}" tabindex="0" th:attr="data-id=${t.id}" role="button" aria-roledescription="draggable card">
<svg class="i grip"><use href="#i-drag"/></svg>
<span class="task-title" th:text="${t.title}">Task</span>
<span class="label" th:classappend="'label-' + ${t.label}" th:text="${t.label}">Label</span>
</div>
</div>
</section>
</div>
src/main/resources/static/css/app.css
Kanban board, the drag ghost and the drop placeholder
.board { display: grid; grid-template-columns: repeat(3, minmax(12.5rem, 1fr)); gap: .85rem; overflow-x: auto; padding-bottom: .25rem; }
.column { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: .75rem; display: flex; flex-direction: column; gap: .6rem; min-height: 18rem; }
.column-head { display: flex; align-items: center; gap: .5rem; font-weight: 700; font-size: .85rem; padding: .1rem .25rem .3rem; }
.column-head .dot { width: .6rem; height: .6rem; border-radius: 50%; }
.column-head .count { margin-left: auto; font-size: .72rem; background: var(--surface); padding: 0 .5rem; border-radius: 999px; color: var(--text-3); border: 1px solid var(--border); }
.column-body { display: flex; flex-direction: column; gap: .6rem; flex: 1; border-radius: var(--radius); transition: background .15s; min-height: 3rem; }
.column.over .column-body { background: var(--primary-soft); }
.task {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .7rem .8rem; box-shadow: var(--shadow-sm);
cursor: grab; touch-action: none; display: flex; gap: .55rem; align-items: flex-start; font-size: .88rem; user-select: none;
}
.task .grip { color: var(--text-3); margin-top: .1rem; }
.task:focus-visible { outline: 3px solid var(--ring); }
.task-title { font-weight: 600; flex: 1; }
.task.lifted { opacity: .35; }
.task-ghost { position: fixed; z-index: 600; pointer-events: none; transform: rotate(2.5deg); box-shadow: var(--shadow-lg); cursor: grabbing; opacity: .95; }
.placeholder { border: 2px dashed var(--primary); border-radius: var(--radius); background: var(--primary-soft); flex: none; }
.label { font-size: .68rem; font-weight: 700; padding: .05rem .45rem; border-radius: 999px; }
.label-Design { background: #fce7f3; color: #9d174d; } .label-Feature { background: #dbeafe; color: #1e40af; } .label-Bug { background: #fee2e2; color: #b91c1c; }
.label-Docs { background: #fef9c3; color: #854d0e; } .label-Backend { background: #dcfce7; color: #166534; }
html[data-theme="dark"] .label { filter: brightness(.85) saturate(.9); }
body.dragging, body.dragging * { cursor: grabbing !important; }
src/main/resources/static/js/demos.js
Send the new order of every column to the server
async function save(movedTitle) {
const body = { columns: {} };
columns().forEach((c) => { body.columns[c.dataset.status] = $$('.task', c).map((t) => Number(t.dataset.id)); });
updateCounts();
try {
await api('/api/tasks/board', { method: 'PUT', body });
$('#boardStatus').textContent = `Saved "${movedTitle}" at ${new Date().toLocaleTimeString()} (UPDATE task_card SET status, position).`;
} catch (e) {
toast({ type: 'error', title: 'Not saved', message: e.message });
}
}
src/main/resources/static/js/demos.js
Pointer Events drag: ghost follows the pointer, placeholder marks the drop spot
let drag = null;
board.addEventListener('pointerdown', (e) => {
const card = e.target.closest('.task');
if (!card || e.button !== 0) return;
const r = card.getBoundingClientRect();
drag = { card, startX: e.clientX, startY: e.clientY, dx: e.clientX - r.left, dy: e.clientY - r.top, started: false, pointerId: e.pointerId };
});
document.addEventListener('pointermove', (e) => {
if (!drag) return;
if (!drag.started) {
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 5) return; // a click, not a drag
drag.started = true;
const r = drag.card.getBoundingClientRect();
drag.ghost = drag.card.cloneNode(true);
drag.ghost.classList.add('task-ghost');
drag.ghost.style.width = r.width + 'px';
document.body.appendChild(drag.ghost);
drag.placeholder = document.createElement('div');
drag.placeholder.className = 'placeholder';
drag.placeholder.style.height = r.height + 'px';
drag.card.after(drag.placeholder);
drag.card.classList.add('lifted');
drag.card.style.display = 'none';
document.body.classList.add('dragging');
}
e.preventDefault();
drag.ghost.style.left = e.clientX - drag.dx + 'px';
drag.ghost.style.top = e.clientY - drag.dy + 'px';
const under = document.elementFromPoint(e.clientX, e.clientY);
const column = under && under.closest('.column');
columns().forEach((c) => c.classList.toggle('over', c === column));
if (!column) return;
const body = $('.column-body', column);
const after = $$('.task', body).find((t) => t !== drag.card && e.clientY < t.getBoundingClientRect().top + t.offsetHeight / 2);
if (after) body.insertBefore(drag.placeholder, after); else body.appendChild(drag.placeholder);
});
function endDrag() {
if (!drag) return;
const d = drag; drag = null;
if (!d.started) return;
d.placeholder.replaceWith(d.card);
d.card.style.display = '';
d.card.classList.remove('lifted');
d.ghost.remove();
columns().forEach((c) => c.classList.remove('over'));
document.body.classList.remove('dragging');
d.card.focus({ preventScroll: true });
save($('.task-title', d.card).textContent);
}
document.addEventListener('pointerup', endDrag);
document.addEventListener('pointercancel', endDrag);
src/main/resources/static/js/demos.js
Keyboard alternative: Alt + arrows
board.addEventListener('keydown', (e) => {
const card = e.target.closest('.task');
if (!card || !e.altKey) return;
const cols = columns(), col = card.closest('.column'), ci = cols.indexOf(col);
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
const target = cols[ci + (e.key === 'ArrowRight' ? 1 : -1)];
if (!target) return;
$('.column-body', target).prepend(card);
} else if (e.key === 'ArrowUp' && card.previousElementSibling) card.previousElementSibling.before(card);
else if (e.key === 'ArrowDown' && card.nextElementSibling) card.nextElementSibling.after(card);
else return;
e.preventDefault();
card.focus();
save($('.task-title', card).textContent);
});