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
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
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
- Google Fonts — Inter https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap
Already linked inside the component's files — no extra setup needed.
Desktop Preview
HTML
html1<!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 & 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
css1/* 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
javascript1// ===================================================== 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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?

Written by
MetaSkillGroup
September 27, 2026