UI Component
✨ New🔥 TrendingData Display

Drag & Drop Kanban Board with Local Storage

A fully working Kanban task board — drag cards between columns with mouse or touch, add, edit and delete tasks, labels, priorities, due dates with overdue warnings, WIP limits, search and label filters, undo delete and automatic saving to localStorage. Supports light and dark mode. Pure HTML, CSS and vanilla JavaScript.

September 27, 20262 views
  • Kanban Board
  • kanban
  • drag-and-drop
  • task-boards
  • todo-app
  • localstorage
  • productivity
  • vanilla-javascript
Drag & Drop Kanban Board with Local Storage
Free

Download instantly, no signup required.

Features Included

Drag & drop between and within columns (mouse, and long-press on touch)
Drop placeholder, floating drag preview and edge auto-scroll
Add cards inline (Enter to save), edit in a modal, delete with undo
Labels, priority bars, assignee avatars and due dates with overdue warning
WIP limit warning on the 'In progress' column
Live search and label filters
Saves automatically to localStorage, follows system light/dark mode
Dependencies
Google Fonts — Intercdn

Drag & Drop Kanban Board with Local Storage

A real, usable Kanban board in plain JavaScript — smooth drag and drop on desktop and mobile, task editing, filters and automatic saving.


What's Included

  • Drag & drop between and within columns (mouse, and long-press on touch)
  • Drop placeholder, floating drag preview and edge auto-scroll
  • Add cards inline (Enter to save), edit in a modal, delete with undo
  • Labels, priority bars, assignee avatars and due dates with overdue warning
  • WIP limit warning on the 'In progress' column
  • Live search and label filters
  • Saves automatically to localStorage, follows system light/dark mode

Dependencies

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
5    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6    <title>Drag &amp; Drop Kanban Board | MetaSkillGroup</title>
7    <link rel="stylesheet" href="style.css" />
8  </head>
9  <body>
10    <!-- created by metaskillgroup-team -->
11    <div class="app">
12      <!-- Top bar -->
13      <header class="topbar">
14        <div class="title">
15          <span class="board-icon">▦</span>
16          <div>
17            <h1>Product Launch</h1>
18            <p class="sub"><span class="total-count">0</span> tasks · drag cards between columns</p>
19          </div>
20        </div>
21        <div class="tools">
22          <label class="search">
23            <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M20 20l-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
24            <input type="search" placeholder="Filter tasks..." aria-label="Filter tasks" />
25          </label>
26          <div class="label-filter" role="group" aria-label="Filter by label"></div>
27          <button class="btn-ghost reset" title="Reset the board to demo data">Reset</button>
28        </div>
29      </header>
30
31      <!-- Board (columns rendered from script.js) -->
32      <main class="board" aria-label="Kanban board"></main>
33    </div>
34
35    <!-- Edit modal -->
36    <div class="modal-backdrop" aria-hidden="true">
37      <form class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
38        <header>
39          <h2 id="modalTitle">Edit task</h2>
40          <button type="button" class="icon-btn close" aria-label="Close">✕</button>
41        </header>
42        <label class="m-field">
43          <span>Title</span>
44          <input name="title" required maxlength="80" />
45        </label>
46        <label class="m-field">
47          <span>Description</span>
48          <textarea name="desc" rows="3" maxlength="300"></textarea>
49        </label>
50        <div class="m-row">
51          <label class="m-field">
52            <span>Label</span>
53            <select name="label"></select>
54          </label>
55          <label class="m-field">
56            <span>Priority</span>
57            <select name="priority">
58              <option value="low">Low</option>
59              <option value="medium">Medium</option>
60              <option value="high">High</option>
61            </select>
62          </label>
63          <label class="m-field">
64            <span>Due date</span>
65            <input type="date" name="due" />
66          </label>
67        </div>
68        <footer>
69          <button type="button" class="btn-danger delete">Delete task</button>
70          <div>
71            <button type="button" class="btn-ghost close">Cancel</button>
72            <button type="submit" class="btn-primary">Save</button>
73          </div>
74        </footer>
75      </form>
76    </div>
77
78    <div class="toast" role="status"></div>
79
80    <script src="script.js"></script>
81  </body>
82</html>

CSS

css
1/* Importing Google Fonts - Inter */
2@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
3
4* {
5  margin: 0;
6  padding: 0;
7  box-sizing: border-box;
8  font-family: "Inter", sans-serif;
9}
10
11:root {
12  --bg: #f5f5f4;
13  --column: #ebeae7;
14  --card: #ffffff;
15  --text: #1c1b19;
16  --muted: #74716b;
17  --border: #e0ded9;
18  --accent: #6d5dfc;
19  --danger: #e5484d;
20  --shadow: 0 1px 2px rgba(28, 27, 25, 0.06), 0 1px 1px rgba(28, 27, 25, 0.04);
21}
22
23@media (prefers-color-scheme: dark) {
24  :root {
25    --bg: #111110;
26    --column: #1b1b19;
27    --card: #232321;
28    --text: #eeeeec;
29    --muted: #8f8d87;
30    --border: #2e2e2b;
31    --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
32  }
33}
34
35body {
36  min-height: 100vh;
37  background: var(--bg);
38  color: var(--text);
39}
40
41button,
42input,
43select,
44textarea {
45  font: inherit;
46  color: inherit;
47}
48
49button {
50  cursor: pointer;
51  border: none;
52  background: none;
53}
54
55.app {
56  display: flex;
57  flex-direction: column;
58  min-height: 100vh;
59  padding: 24px;
60  gap: 20px;
61}
62
63.sub {
64  font-size: 0.85rem;
65  color: var(--muted);
66}
67
68/* ---------- Top bar ---------- */
69.topbar {
70  display: flex;
71  justify-content: space-between;
72  align-items: center;
73  gap: 16px;
74  flex-wrap: wrap;
75}
76
77.title {
78  display: flex;
79  align-items: center;
80  gap: 12px;
81}
82
83.board-icon {
84  display: grid;
85  place-items: center;
86  width: 42px;
87  height: 42px;
88  border-radius: 12px;
89  font-size: 1.3rem;
90  color: #fff;
91  background: linear-gradient(135deg, var(--accent), #a78bfa);
92}
93
94.title h1 {
95  font-size: 1.35rem;
96  font-weight: 700;
97  letter-spacing: -0.02em;
98}
99
100.tools {
101  display: flex;
102  align-items: center;
103  gap: 10px;
104  flex-wrap: wrap;
105}
106
107.search {
108  display: flex;
109  align-items: center;
110  gap: 8px;
111  padding: 0 12px;
112  height: 38px;
113  border-radius: 10px;
114  color: var(--muted);
115  background: var(--card);
116  border: 1px solid var(--border);
117}
118
119.search:focus-within {
120  border-color: var(--accent);
121}
122
123.search input {
124  width: 170px;
125  border: none;
126  outline: none;
127  background: none;
128  font-size: 0.9rem;
129}
130
131.label-filter {
132  display: flex;
133  gap: 6px;
134}
135
136.label-chip {
137  padding: 6px 10px;
138  border-radius: 99px;
139  font-size: 0.78rem;
140  font-weight: 600;
141  color: var(--c);
142  background: color-mix(in srgb, var(--c) 14%, transparent);
143  border: 1.5px solid transparent;
144  transition:
145    border-color 0.2s ease,
146    opacity 0.2s ease;
147}
148
149.label-filter.filtering .label-chip:not(.on) {
150  opacity: 0.45;
151}
152
153.label-chip.on {
154  border-color: var(--c);
155}
156
157.btn-ghost,
158.btn-primary,
159.btn-danger {
160  height: 38px;
161  padding: 0 16px;
162  border-radius: 10px;
163  font-size: 0.88rem;
164  font-weight: 600;
165  transition:
166    background 0.2s ease,
167    transform 0.15s ease;
168}
169
170.btn-ghost {
171  background: var(--card);
172  border: 1px solid var(--border);
173}
174
175.btn-ghost:hover {
176  background: var(--column);
177}
178
179.btn-primary {
180  color: #fff;
181  background: var(--accent);
182}
183
184.btn-primary:hover {
185  background: #5a49f0;
186}
187
188.btn-danger {
189  color: var(--danger);
190}
191
192.btn-danger:hover {
193  background: color-mix(in srgb, var(--danger) 12%, transparent);
194}
195
196/* ---------- Board ---------- */
197.board {
198  flex: 1;
199  display: grid;
200  grid-auto-flow: column;
201  grid-auto-columns: minmax(280px, 1fr);
202  gap: 16px;
203  overflow-x: auto;
204  padding-bottom: 8px;
205  scroll-snap-type: x mandatory;
206}
207
208.column {
209  display: flex;
210  flex-direction: column;
211  min-height: 0;
212  max-height: calc(100vh - 140px);
213  padding: 12px;
214  border-radius: 16px;
215  background: var(--column);
216  scroll-snap-align: start;
217  transition:
218    background 0.2s ease,
219    box-shadow 0.2s ease;
220}
221
222.column.drop-target {
223  box-shadow: inset 0 0 0 2px var(--accent);
224}
225
226.column.over-limit .col-count {
227  color: #fff;
228  background: var(--danger);
229}
230
231.col-head {
232  display: flex;
233  align-items: center;
234  gap: 8px;
235  padding: 4px 4px 12px;
236}
237
238.col-dot {
239  width: 10px;
240  height: 10px;
241  border-radius: 50%;
242  background: var(--c);
243  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c) 25%, transparent);
244}
245
246.col-head h2 {
247  font-size: 0.92rem;
248  font-weight: 600;
249}
250
251.col-count {
252  padding: 1px 8px;
253  border-radius: 99px;
254  font-size: 0.75rem;
255  font-weight: 600;
256  color: var(--muted);
257  background: var(--card);
258  transition:
259    background 0.2s ease,
260    color 0.2s ease;
261}
262
263.col-limit {
264  margin-left: auto;
265  font-size: 0.72rem;
266  color: var(--muted);
267}
268
269.cards {
270  flex: 1;
271  display: flex;
272  flex-direction: column;
273  gap: 10px;
274  min-height: 60px;
275  overflow-y: auto;
276  padding: 2px;
277  margin: -2px;
278}
279
280/* ---------- Card ---------- */
281.card {
282  position: relative;
283  padding: 14px;
284  border-radius: 12px;
285  background: var(--card);
286  border: 1px solid var(--border);
287  box-shadow: var(--shadow);
288  cursor: grab;
289  user-select: none;
290  -webkit-user-select: none;
291  -webkit-touch-callout: none;
292  transition:
293    transform 0.15s ease,
294    box-shadow 0.15s ease,
295    opacity 0.2s ease;
296  animation: pop 0.3s ease;
297}
298
299@keyframes pop {
300  from {
301    opacity: 0;
302    transform: scale(0.96);
303  }
304}
305
306.card:hover {
307  box-shadow: 0 6px 16px rgba(28, 27, 25, 0.08);
308}
309
310.card:focus-visible {
311  outline: 2px solid var(--accent);
312  outline-offset: 2px;
313}
314
315.card.hidden {
316  display: none;
317}
318
319/* The original card stays in place as a faded "ghost" while dragging */
320.card.dragging-source {
321  opacity: 0.35;
322}
323
324/* Floating copy that follows the pointer */
325.card.drag-clone {
326  position: fixed;
327  z-index: 1000;
328  pointer-events: none;
329  cursor: grabbing;
330  animation: none;
331  transform: rotate(3deg) scale(1.03);
332  box-shadow: 0 20px 40px rgba(28, 27, 25, 0.25);
333}
334
335.placeholder {
336  flex-shrink: 0;
337  border-radius: 12px;
338  border: 2px dashed color-mix(in srgb, var(--accent) 60%, transparent);
339  background: color-mix(in srgb, var(--accent) 8%, transparent);
340}
341
342.card-top {
343  display: flex;
344  justify-content: space-between;
345  align-items: center;
346  margin-bottom: 8px;
347}
348
349.tag {
350  padding: 2px 8px;
351  border-radius: 6px;
352  font-size: 0.72rem;
353  font-weight: 600;
354  color: var(--c);
355  background: color-mix(in srgb, var(--c) 14%, transparent);
356}
357
358.priority {
359  display: flex;
360  align-items: flex-end;
361  gap: 2px;
362  height: 12px;
363}
364
365.priority i {
366  width: 3px;
367  border-radius: 2px;
368  background: var(--border);
369}
370
371.priority i:nth-child(1) {
372  height: 5px;
373}
374
375.priority i:nth-child(2) {
376  height: 8px;
377}
378
379.priority i:nth-child(3) {
380  height: 12px;
381}
382
383.priority.low i:nth-child(1),
384.priority.medium i:nth-child(-n + 2) {
385  background: #f5a524;
386}
387
388.priority.high i {
389  background: var(--danger);
390}
391
392.card h3 {
393  font-size: 0.92rem;
394  font-weight: 600;
395  line-height: 1.4;
396}
397
398.card p {
399  margin-top: 4px;
400  font-size: 0.8rem;
401  line-height: 1.5;
402  color: var(--muted);
403  display: -webkit-box;
404  -webkit-line-clamp: 2;
405  -webkit-box-orient: vertical;
406  overflow: hidden;
407}
408
409.card-foot {
410  display: flex;
411  justify-content: space-between;
412  align-items: center;
413  margin-top: 12px;
414}
415
416.due {
417  display: flex;
418  align-items: center;
419  gap: 4px;
420  font-size: 0.75rem;
421  color: var(--muted);
422}
423
424.due.overdue {
425  color: var(--danger);
426  font-weight: 600;
427}
428
429.avatar {
430  display: grid;
431  place-items: center;
432  width: 26px;
433  height: 26px;
434  border-radius: 50%;
435  font-size: 0.65rem;
436  font-weight: 700;
437  color: #fff;
438  background: hsl(var(--h) 60% 55%);
439}
440
441/* ---------- Add card ---------- */
442.add-btn {
443  display: flex;
444  align-items: center;
445  gap: 6px;
446  margin-top: 10px;
447  padding: 10px;
448  border-radius: 10px;
449  font-size: 0.88rem;
450  font-weight: 500;
451  color: var(--muted);
452  transition:
453    background 0.2s ease,
454    color 0.2s ease;
455}
456
457.add-btn:hover {
458  color: var(--text);
459  background: color-mix(in srgb, var(--card) 70%, transparent);
460}
461
462.add-form {
463  display: none;
464  margin-top: 10px;
465}
466
467.column.adding .add-form {
468  display: block;
469}
470
471.column.adding .add-btn {
472  display: none;
473}
474
475.add-form textarea {
476  width: 100%;
477  padding: 10px 12px;
478  border-radius: 10px;
479  border: 1.5px solid var(--accent);
480  background: var(--card);
481  font-size: 0.9rem;
482  resize: none;
483  outline: none;
484}
485
486.add-actions {
487  display: flex;
488  gap: 8px;
489  margin-top: 8px;
490}
491
492.add-actions .btn-primary,
493.add-actions .btn-ghost {
494  height: 34px;
495}
496
497/* ---------- Modal ---------- */
498.modal-backdrop {
499  position: fixed;
500  inset: 0;
501  z-index: 2000;
502  display: grid;
503  place-items: center;
504  padding: 16px;
505  background: rgba(10, 10, 9, 0.5);
506  backdrop-filter: blur(4px);
507  opacity: 0;
508  visibility: hidden;
509  transition:
510    opacity 0.2s ease,
511    visibility 0.2s;
512}
513
514.modal-backdrop.open {
515  opacity: 1;
516  visibility: visible;
517}
518
519.modal {
520  width: 100%;
521  max-width: 520px;
522  padding: 22px;
523  border-radius: 18px;
524  background: var(--card);
525  border: 1px solid var(--border);
526  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.3);
527  transform: translateY(12px) scale(0.98);
528  transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
529}
530
531.modal-backdrop.open .modal {
532  transform: none;
533}
534
535.modal header,
536.modal footer {
537  display: flex;
538  justify-content: space-between;
539  align-items: center;
540}
541
542.modal header {
543  margin-bottom: 16px;
544}
545
546.modal h2 {
547  font-size: 1.1rem;
548}
549
550.icon-btn {
551  width: 32px;
552  height: 32px;
553  border-radius: 8px;
554  color: var(--muted);
555}
556
557.icon-btn:hover {
558  background: var(--column);
559}
560
561.m-field {
562  display: flex;
563  flex-direction: column;
564  gap: 6px;
565  margin-bottom: 14px;
566  font-size: 0.8rem;
567  font-weight: 600;
568  color: var(--muted);
569}
570
571.m-field input,
572.m-field textarea,
573.m-field select {
574  padding: 10px 12px;
575  border-radius: 10px;
576  border: 1.5px solid var(--border);
577  background: var(--bg);
578  font-size: 0.93rem;
579  font-weight: 400;
580  color: var(--text);
581  outline: none;
582  resize: vertical;
583}
584
585.m-field input:focus,
586.m-field textarea:focus,
587.m-field select:focus {
588  border-color: var(--accent);
589}
590
591.m-row {
592  display: grid;
593  grid-template-columns: repeat(3, 1fr);
594  gap: 10px;
595}
596
597.modal footer {
598  margin-top: 6px;
599}
600
601.modal footer > div {
602  display: flex;
603  gap: 8px;
604}
605
606/* ---------- Toast ---------- */
607.toast {
608  position: fixed;
609  left: 50%;
610  bottom: 24px;
611  z-index: 3000;
612  padding: 12px 18px;
613  border-radius: 12px;
614  font-size: 0.88rem;
615  font-weight: 500;
616  color: #fff;
617  background: #1c1b19;
618  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
619  transform: translate(-50%, 100px);
620  transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
621}
622
623.toast.show {
624  transform: translate(-50%, 0);
625}
626
627.toast button {
628  margin-left: 12px;
629  font-weight: 700;
630  color: #a78bfa;
631}
632
633/* ---------- Responsive ---------- */
634@media (max-width: 700px) {
635  .app {
636    padding: 16px 12px;
637  }
638
639  .tools,
640  .search {
641    width: 100%;
642  }
643
644  .search input {
645    width: 100%;
646  }
647
648  .board {
649    grid-auto-columns: 85%;
650  }
651
652  .column {
653    max-height: none;
654  }
655
656  .m-row {
657    grid-template-columns: 1fr 1fr;
658  }
659}
660
661@media (prefers-reduced-motion: reduce) {
662  *,
663  *::before,
664  *::after {
665    animation: none !important;
666    transition-duration: 0.01ms !important;
667  }
668}
669

JavaScript

javascript
1// =====================================================
2// Kanban Board — drag & drop with pointer events
3// (works with mouse and touch), saved in localStorage
4// =====================================================
5
6const STORAGE_KEY = "metaskill-kanban-v1";
7
8const COLUMNS = [
9  { id: "backlog", title: "Backlog", color: "#8f8d87" },
10  { id: "todo", title: "To do", color: "#3b82f6" },
11  { id: "progress", title: "In progress", color: "#f5a524", limit: 3 },
12  { id: "done", title: "Done", color: "#22c55e" },
13];
14
15const LABELS = {
16  design: { name: "Design", color: "#d946ef" },
17  frontend: { name: "Frontend", color: "#6d5dfc" },
18  backend: { name: "Backend", color: "#0ea5e9" },
19  marketing: { name: "Marketing", color: "#f97316" },
20  bug: { name: "Bug", color: "#e5484d" },
21};
22
23const daysFromNow = (n) => {
24  const d = new Date();
25  d.setDate(d.getDate() + n);
26  return d.toISOString().slice(0, 10);
27};
28
29const demoTasks = () => [
30  { id: 1, col: "backlog", title: "Competitor pricing research", desc: "Compare the top 5 competitors and summarise the findings.", label: "marketing", priority: "low", due: daysFromNow(12), who: "PL" },
31  { id: 2, col: "backlog", title: "Dark mode for settings page", desc: "", label: "frontend", priority: "low", due: "", who: "RS" },
32  { id: 3, col: "todo", title: "Design onboarding screens", desc: "Three steps: welcome, connect data, invite team.", label: "design", priority: "high", due: daysFromNow(3), who: "AK" },
33  { id: 4, col: "todo", title: "Set up Stripe webhooks", desc: "Handle subscription created, updated and cancelled.", label: "backend", priority: "medium", due: daysFromNow(5), who: "MJ" },
34  { id: 5, col: "todo", title: "Write launch blog post", desc: "", label: "marketing", priority: "medium", due: daysFromNow(8), who: "PL" },
35  { id: 6, col: "progress", title: "Landing page hero section", desc: "Animated mockup + new headline copy.", label: "frontend", priority: "high", due: daysFromNow(1), who: "RS" },
36  { id: 7, col: "progress", title: "Fix login redirect loop", desc: "Happens on Safari when cookies are blocked.", label: "bug", priority: "high", due: daysFromNow(-1), who: "MJ" },
37  { id: 8, col: "done", title: "Brand colour palette", desc: "", label: "design", priority: "medium", due: daysFromNow(-4), who: "AK" },
38  { id: 9, col: "done", title: "Database schema v2", desc: "Migrations tested on staging.", label: "backend", priority: "medium", due: daysFromNow(-6), who: "MJ" },
39];
40
41// ---------- State ----------
42const load = () => {
43  try {
44    const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
45    if (Array.isArray(saved)) return saved;
46  } catch {
47    /* storage blocked or corrupt — fall back to demo data */
48  }
49  return demoTasks();
50};
51
52let tasks = load();
53let filterText = "";
54let filterLabel = null;
55
56const save = () => {
57  try {
58    localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
59  } catch {
60    /* ignore — the board still works without persistence */
61  }
62};
63
64// ---------- Elements ----------
65const board = document.querySelector(".board");
66const searchInput = document.querySelector(".search input");
67const labelFilter = document.querySelector(".label-filter");
68const totalCount = document.querySelector(".total-count");
69const backdrop = document.querySelector(".modal-backdrop");
70const modal = document.querySelector(".modal");
71const toast = document.querySelector(".toast");
72
73const escapeHTML = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
74
75// ---------- Rendering ----------
76const cardHTML = (t) => {
77  const label = LABELS[t.label];
78  const hue = [...t.who].reduce((s, c) => s + c.charCodeAt(0) * 37, 0) % 360;
79  const today = new Date().toISOString().slice(0, 10);
80  const overdue = t.due && t.due < today && t.col !== "done";
81  const dueText = t.due ? new Date(t.due + "T00:00").toLocaleDateString("en", { month: "short", day: "numeric" }) : "";
82
83  return `
84    <article class="card" data-id="${t.id}" tabindex="0" aria-label="${escapeHTML(t.title)}">
85      <div class="card-top">
86        <span class="tag" style="--c: ${label.color}">${label.name}</span>
87        <span class="priority ${t.priority}" title="${t.priority} priority"><i></i><i></i><i></i></span>
88      </div>
89      <h3>${escapeHTML(t.title)}</h3>
90      ${t.desc ? `<p>${escapeHTML(t.desc)}</p>` : ""}
91      <div class="card-foot">
92        <span class="due ${overdue ? "overdue" : ""}">${dueText ? `📅 ${dueText}${overdue ? " · overdue" : ""}` : ""}</span>
93        <span class="avatar" style="--h: ${hue}">${t.who}</span>
94      </div>
95    </article>`;
96};
97
98const matchesFilter = (t) => {
99  const text = `${t.title} ${t.desc}`.toLowerCase();
100  return (!filterText || text.includes(filterText)) && (!filterLabel || t.label === filterLabel);
101};
102
103const render = () => {
104  board.innerHTML = COLUMNS.map((col) => {
105    const colTasks = tasks.filter((t) => t.col === col.id);
106    const overLimit = col.limit && colTasks.length > col.limit;
107    return `
108      <section class="column ${overLimit ? "over-limit" : ""}" data-col="${col.id}">
109        <header class="col-head">
110          <span class="col-dot" style="--c: ${col.color}"></span>
111          <h2>${col.title}</h2>
112          <span class="col-count">${colTasks.length}</span>
113          ${col.limit ? `<span class="col-limit">WIP limit ${col.limit}</span>` : ""}
114        </header>
115        <div class="cards">${colTasks.map(cardHTML).join("")}</div>
116        <button class="add-btn" type="button">+ Add a card</button>
117        <form class="add-form">
118          <textarea rows="2" placeholder="What needs to be done?" maxlength="80"></textarea>
119          <div class="add-actions">
120            <button class="btn-primary" type="submit">Add card</button>
121            <button class="btn-ghost cancel-add" type="button">Cancel</button>
122          </div>
123        </form>
124      </section>`;
125  }).join("");
126
127  applyFilter();
128  totalCount.textContent = tasks.length;
129};
130
131const applyFilter = () => {
132  board.querySelectorAll(".card").forEach((card) => {
133    const task = tasks.find((t) => t.id === Number(card.dataset.id));
134    card.classList.toggle("hidden", !matchesFilter(task));
135  });
136};
137
138// ---------- Filters ----------
139labelFilter.innerHTML = Object.entries(LABELS)
140  .map(([key, l]) => `<button class="label-chip" data-label="${key}" style="--c: ${l.color}">${l.name}</button>`)
141  .join("");
142
143labelFilter.addEventListener("click", (e) => {
144  const chip = e.target.closest(".label-chip");
145  if (!chip) return;
146  filterLabel = filterLabel === chip.dataset.label ? null : chip.dataset.label;
147  labelFilter.querySelectorAll(".label-chip").forEach((c) => c.classList.toggle("on", c.dataset.label === filterLabel));
148  labelFilter.classList.toggle("filtering", !!filterLabel);
149  applyFilter();
150});
151
152searchInput.addEventListener("input", () => {
153  filterText = searchInput.value.trim().toLowerCase();
154  applyFilter();
155});
156
157// ---------- Add card ----------
158board.addEventListener("click", (e) => {
159  const column = e.target.closest(".column");
160  if (!column) return;
161
162  if (e.target.closest(".add-btn")) {
163    board.querySelectorAll(".column.adding").forEach((c) => c.classList.remove("adding"));
164    column.classList.add("adding");
165    column.querySelector(".add-form textarea").focus();
166  }
167  if (e.target.closest(".cancel-add")) column.classList.remove("adding");
168});
169
170board.addEventListener("submit", (e) => {
171  e.preventDefault();
172  const column = e.target.closest(".column");
173  const textarea = e.target.querySelector("textarea");
174  const title = textarea.value.trim();
175  if (!title) return textarea.focus();
176
177  tasks.push({
178    id: Date.now(),
179    col: column.dataset.col,
180    title,
181    desc: "",
182    label: filterLabel || "frontend",
183    priority: "medium",
184    due: "",
185    who: "ME",
186  });
187  save();
188  render();
189  // Keep the form open for adding several cards in a row
190  const col = board.querySelector(`[data-col="${column.dataset.col}"]`);
191  col.classList.add("adding");
192  col.querySelector(".add-form textarea").focus();
193});
194
195// Enter submits, Shift+Enter adds a new line, Esc closes
196board.addEventListener("keydown", (e) => {
197  if (e.target.matches(".add-form textarea")) {
198    if (e.key === "Enter" && !e.shiftKey) {
199      e.preventDefault();
200      e.target.form.requestSubmit();
201    }
202    if (e.key === "Escape") e.target.closest(".column").classList.remove("adding");
203  }
204  if (e.target.matches(".card") && e.key === "Enter") openModal(Number(e.target.dataset.id));
205});
206
207// ---------- Edit modal ----------
208let editingId = null;
209
210modal.elements.label.innerHTML = Object.entries(LABELS)
211  .map(([key, l]) => `<option value="${key}">${l.name}</option>`)
212  .join("");
213
214function openModal(id) {
215  const t = tasks.find((x) => x.id === id);
216  if (!t) return;
217  editingId = id;
218  modal.elements.title.value = t.title;
219  modal.elements.desc.value = t.desc;
220  modal.elements.label.value = t.label;
221  modal.elements.priority.value = t.priority;
222  modal.elements.due.value = t.due;
223  backdrop.classList.add("open");
224  backdrop.setAttribute("aria-hidden", "false");
225  setTimeout(() => modal.elements.title.focus(), 50);
226}
227
228const closeModal = () => {
229  backdrop.classList.remove("open");
230  backdrop.setAttribute("aria-hidden", "true");
231  editingId = null;
232};
233
234modal.addEventListener("submit", (e) => {
235  e.preventDefault();
236  const t = tasks.find((x) => x.id === editingId);
237  const title = modal.elements.title.value.trim();
238  if (!t || !title) return;
239  Object.assign(t, {
240    title,
241    desc: modal.elements.desc.value.trim(),
242    label: modal.elements.label.value,
243    priority: modal.elements.priority.value,
244    due: modal.elements.due.value,
245  });
246  save();
247  render();
248  closeModal();
249});
250
251modal.querySelectorAll(".close").forEach((b) => b.addEventListener("click", closeModal));
252backdrop.addEventListener("click", (e) => {
253  if (e.target === backdrop) closeModal();
254});
255document.addEventListener("keydown", (e) => {
256  if (e.key === "Escape" && backdrop.classList.contains("open")) closeModal();
257});
258
259// Delete with undo
260let toastTimer;
261modal.querySelector(".delete").addEventListener("click", () => {
262  const index = tasks.findIndex((x) => x.id === editingId);
263  if (index === -1) return;
264  const [removed] = tasks.splice(index, 1);
265  save();
266  render();
267  closeModal();
268
269  toast.innerHTML = `Task deleted <button type="button">Undo</button>`;
270  toast.classList.add("show");
271  clearTimeout(toastTimer);
272  toastTimer = setTimeout(() => toast.classList.remove("show"), 4000);
273  toast.querySelector("button").onclick = () => {
274    tasks.splice(index, 0, removed);
275    save();
276    render();
277    toast.classList.remove("show");
278  };
279});
280
281document.querySelector(".reset").addEventListener("click", () => {
282  if (!confirm("Reset the board to the demo tasks?")) return;
283  tasks = demoTasks();
284  save();
285  render();
286});
287
288// ---------- Drag & drop ----------
289// Mouse: drag starts after moving 5px. Touch: long-press 250ms, so normal
290// scrolling on phones still works.
291let drag = null;
292let pending = null;
293
294board.addEventListener("pointerdown", (e) => {
295  const card = e.target.closest(".card");
296  if (!card || e.button > 0) return;
297
298  pending = { card, startX: e.clientX, startY: e.clientY, pointerId: e.pointerId, type: e.pointerType, ready: e.pointerType === "mouse" };
299
300  if (e.pointerType !== "mouse") {
301    pending.timer = setTimeout(() => {
302      if (!pending) return;
303      pending.ready = true;
304      navigator.vibrate?.(15);
305      startDrag(e.clientX, e.clientY);
306    }, 250);
307  }
308});
309
310window.addEventListener("pointermove", (e) => {
311  if (drag) {
312    moveDrag(e.clientX, e.clientY);
313    return;
314  }
315  if (!pending) return;
316  const moved = Math.hypot(e.clientX - pending.startX, e.clientY - pending.startY);
317  if (pending.type === "mouse") {
318    if (moved > 5) startDrag(e.clientX, e.clientY);
319    return;
320  }
321  // Finger moved before the long-press finished: treat it as a scroll
322  if (!pending.ready && moved > 8) cancelPending();
323});
324
325window.addEventListener("pointerup", (e) => {
326  if (drag) return endDrag();
327  // A click without dragging opens the editor
328  if (pending && e.target.closest(".card") === pending.card) openModal(Number(pending.card.dataset.id));
329  cancelPending();
330});
331
332window.addEventListener("pointercancel", () => {
333  if (drag) endDrag();
334  cancelPending();
335});
336
337// While dragging on touch, stop the page from scrolling
338window.addEventListener(
339  "touchmove",
340  (e) => {
341    if (drag || pending?.ready) e.preventDefault();
342  },
343  { passive: false },
344);
345
346function cancelPending() {
347  if (pending?.timer) clearTimeout(pending.timer);
348  pending = null;
349}
350
351function startDrag(x, y) {
352  const card = pending.card;
353  const rect = card.getBoundingClientRect();
354
355  const clone = card.cloneNode(true);
356  clone.classList.add("drag-clone");
357  clone.style.width = `${rect.width}px`;
358  document.body.appendChild(clone);
359
360  const placeholder = document.createElement("div");
361  placeholder.className = "placeholder";
362  placeholder.style.height = `${rect.height}px`;
363  card.after(placeholder);
364  card.classList.add("dragging-source");
365  card.style.display = "none";
366
367  drag = { card, clone, placeholder, offsetX: x - rect.left, offsetY: y - rect.top, id: Number(card.dataset.id) };
368  cancelPending();
369  document.body.style.cursor = "grabbing";
370  moveDrag(x, y);
371}
372
373function moveDrag(x, y) {
374  const { clone, placeholder, offsetX, offsetY } = drag;
375  clone.style.left = `${x - offsetX}px`;
376  clone.style.top = `${y - offsetY}px`;
377
378  // Which column is under the pointer?
379  const column = document.elementFromPoint(x, y)?.closest(".column");
380  board.querySelectorAll(".column").forEach((c) => c.classList.toggle("drop-target", c === column));
381  if (!column) return;
382
383  // Insert the placeholder before the first card whose middle is below the pointer
384  const list = column.querySelector(".cards");
385  const cards = [...list.querySelectorAll(".card:not(.dragging-source):not(.hidden)")];
386  const before = cards.find((c) => {
387    const r = c.getBoundingClientRect();
388    return y < r.top + r.height / 2;
389  });
390  if (before) list.insertBefore(placeholder, before);
391  else list.appendChild(placeholder);
392
393  autoScroll(x, y, list);
394}
395
396// Scroll the board / column when dragging near an edge
397function autoScroll(x, y, list) {
398  const edge = 60;
399  const b = board.getBoundingClientRect();
400  if (x < b.left + edge) board.scrollLeft -= 12;
401  else if (x > b.right - edge) board.scrollLeft += 12;
402
403  const l = list.getBoundingClientRect();
404  if (y < l.top + edge) list.scrollTop -= 10;
405  else if (y > l.bottom - edge) list.scrollTop += 10;
406}
407
408function endDrag() {
409  const { clone, placeholder, id } = drag;
410  const list = placeholder.parentElement;
411  const newCol = list.closest(".column").dataset.col;
412
413  // Work out the new position from the placeholder's neighbours
414  const task = tasks.find((t) => t.id === id);
415  const nextCard = [...list.children].slice([...list.children].indexOf(placeholder) + 1).find((el) => el.matches(".card:not(.dragging-source)"));
416
417  tasks = tasks.filter((t) => t.id !== id);
418  task.col = newCol;
419  if (nextCard) {
420    const idx = tasks.findIndex((t) => t.id === Number(nextCard.dataset.id));
421    tasks.splice(idx, 0, task);
422  } else {
423    // Append after the last task of that column
424    const lastIdx = tasks.map((t) => t.col).lastIndexOf(newCol);
425    tasks.splice(lastIdx + 1, 0, task);
426  }
427
428  // Animate the clone into the placeholder's spot, then re-render
429  const target = placeholder.getBoundingClientRect();
430  clone.style.transition = "left 0.2s ease, top 0.2s ease, transform 0.2s ease";
431  clone.style.left = `${target.left}px`;
432  clone.style.top = `${target.top}px`;
433  clone.style.transform = "none";
434
435  const finish = () => {
436    clone.remove();
437    save();
438    render();
439  };
440  setTimeout(finish, 200);
441
442  document.body.style.cursor = "";
443  drag = null;
444}
445
446render();
447

Built by Meta Skill Group — copy it, use it, make it yours.

Built With

🌐

HTML5

🎨

CSS3

⚡

JavaScript

Compatibility

Browsers
ChromeFirefoxBraveOperaArcVivaldiSafariEdge
Devices
DesktopSmartwatchTVTabletMobile
Frameworks
Vanilla JSHTML/CSS

Found this useful?

MetaSkillGroup

Written by

MetaSkillGroup

September 27, 2026

❓ FAQs

Subscribe to Newsletter

Get latest updates, tutorials and resources delivered to your inbox.

No spam. Unsubscribe anytime.

Drag & Drop Kanban Board | HTML CSS JS | MetaSkillGroup