Skip to content

Connected kanban with keyboard sorting

Problem

Implement connected sortable columns where cards can move across lists and also be reordered with the keyboard.

Solution

Two connected lists using the same createSortableScope() instance plus keyboard reorder support:

html
<section class="board">
  <div>
    <h3>Todo</h3>
    <ul id="todo-list" class="lane">
      <li data-sort-id="t-1"><span class="handle">⠿</span> Draft API</li>
      <li data-sort-id="t-2"><span class="handle">⠿</span> Add tests</li>
      <li data-sort-id="t-3"><span class="handle">⠿</span> Write docs</li>
    </ul>
  </div>

  <div>
    <h3>Done</h3>
    <ul id="done-list" class="lane">
      <li data-sort-id="d-1"><span class="handle">⠿</span> Setup package</li>
    </ul>
  </div>
</section>
css
.board {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.lane {
  list-style: none;
  margin: 0;
  padding: 0.5rem;
  min-height: 10rem;
  border: 1px solid var(--color-contrast-200);
  border-radius: 0.5rem;
  background: var(--color-bg-soft);
}

.lane > li {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
  padding: 0.5rem 0.625rem;
  border-radius: 0.375rem;
  background: var(--color-bg-default);
}

.handle {
  cursor: grab;
  user-select: none;
  color: var(--color-contrast-500);
}

.lane > li:focus-visible {
  outline: 2px solid var(--color-primary-500);
  outline-offset: 2px;
}

.dnd-placeholder {
  border: 2px dashed var(--color-primary-300);
  border-radius: 0.375rem;
  background: var(--color-primary-50);
  box-sizing: border-box;
}

[data-dragging] {
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
}
ts
import { createSortable, createSortableScope } from '@vielzeug/dnd';

const todoEl = document.getElementById('todo-list')!;
const doneEl = document.getElementById('done-list')!;
const persistMove = (itemId: string, sourceIds: string[], targetIds: string[]) => {
  console.log({ itemId, sourceIds, targetIds });
};
const boardScope = createSortableScope({
  onMove: ({ itemId, sourceIds, targetIds }) => {
    persistMove(itemId, sourceIds, targetIds);
  },
  touch: true,
});

using todoSortable = createSortable({
  element: todoEl,
  handle: '.handle',
  keyboard: true,
  autoScroll: { edgeThreshold: 40, speed: 24, viewport: true },
  getKey: (element) => element.dataset.sortId!,
  scope: boardScope,
});

using doneSortable = createSortable({
  element: doneEl,
  handle: '.handle',
  keyboard: true,
  getKey: (element) => element.dataset.sortId!,
  scope: boardScope,
});

Keyboard behavior:

  • Focus any card.
  • Use arrow keys to move it by one position within its current list.
  • Use Home and End to move to list boundaries.

Pitfalls

  • scope.onMove fires once per cross-list transfer with both final orders. A new scope per list disables cross-list drag.
  • Keyboard reordering fires onReorder on each move, not on drop; debounce persistence calls if needed.
  • using declarations require a runtime that supports Symbol.dispose (or use explicit dispose() calls).