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/sortable';

const todoEl = document.getElementById('todo-list')!;
const doneEl = document.getElementById('done-list')!;
const persistMove = (itemId: string, sourceIds: readonly string[], targetIds: readonly 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).