Docs/Examples

Project board

A product board in the spirit of Linear and Trello — columns you reorder, WIP limits, a question before Done, multi-select with a selection bar, filters the drags work through, a trash with Undo, and a card panel whose Status buttons play real drags.

Live

Loading the demo…

<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Active, type AnnouncedPosition, type Announcements } from '@vue-dnd-kit/core';
import Board from './Board.vue';
import { BOARD, createBoard, type Card, type Column } from './store';

// Provided above the provider: the overlay it renders reads the board too.
const board = createBoard();
provide(BOARD, board);

// Screen readers hear cards and columns by name, not by id.
const subject = (active: Active) => {
  const item = active.items[0]?.item;
  if (active.type.includes('column')) return `the ${(item as Column).title} column`;
  if (active.ids.length > 1) return `${active.ids.length} cards`;
  return `${(item as Card).id}, ${(item as Card).title}`;
};
/** Where it is: "Todo, position 2 of 6." for a card, "Place 2 of 4." for a column. */
const where = (position: AnnouncedPosition | undefined) => {
  if (!position) return '';
  const column = board.column(position.list);
  if (!column) return `Place ${position.index + 1} of ${position.length}.`;
  return `${column.title}, position ${position.index + 1} of ${position.length}.`;
};

const announcements: Partial<Announcements> = {
  onDragStart: ({ active }, { position }) => {
    const keys = active.sensor === 'keyboard' ? ' Arrow keys move it, Space drops it, Escape cancels.' : '';
    return `Picked up ${subject(active)}. ${where(position)}${keys}`;
  },
  onDragOver: ({ over }, { position }) => {
    if (over?.id === 'trash') return 'Over the trash. Drop to delete.';
    const column = over ? board.column(over.id) : undefined;
    if (column && !over!.accepted) return `${column.title} is full: its limit is ${column.limit} cards.`;
    return position ? where(position) : undefined;
  },
  onDragEnd: ({ active, over }, { position }) =>
    over?.id === 'trash' ? `Deleted ${subject(active)}. Undo is in the notice.` : `Dropped ${subject(active)}. ${where(position)}`,
  onDragCancel: ({ active }, { position }) => `${subject(active)} went back. ${where(position)}`,
  onDragResume: ({ active }) => `Not dropped: still holding ${subject(active)}.`,
};
</script>

<template>
  <!--
    Space picks a card up and drops it: Enter is left to open the card. Touch drags vibrate
    over a column that takes them, and when a drop is refused.
  -->
  <DnDProvider
    :keyboard="{ keys: { start: ['Space'] } }"
    screen-reader-instructions="Space picks up a card or a column, the arrow keys move it, Space drops it, Escape cancels. Enter opens a card, X selects it."
    :announcements="announcements"
    :haptics="{ enter: true, refuse: true }"
  >
    <Board />
  </DnDProvider>
</template>

What to look at

  • Board.vue: the columns are a horizontal sortable list of their own (strategy: 'horizontal', axis: 'x'), and each column is a sortable item dragged by its header (handle). Expanded, the board fills the screen; on a phone it scrolls sideways.
  • Column.vue: the cards of every column are one group, with the column's rules: its WIP limit, the question before Done, and the filters. Its header meter lights up the cards a drag would bring in.
  • Card.vue: a sortable item of type: 'card', so the trash takes cards and not columns. A click opens it, Ctrl/Cmd-click selects it (selected); with the keyboard, Space picks it up, Enter opens it and X selects it.
  • store.ts: the board's data and rules, with no drag and drop in them, provided above the DnDProvider so the overlay reads it too.

A column's rules

Each column hands its list four rules. put sees every card of a multi-drag at once: the column takes them all if they fit under its limit, or none. Over a full column the slot stays where the drag started, the column turns red and the cards in hand say why. onBeforeDrop asks before cards enter Done, and animateChanges slides the cards that come and go without a drag: a filter, Add card, Undo.

Column.vue
const { items, isOver, isAccepting } = makeSortableList(list, {
  id: props.column.id,
  items: () => props.column.cards.filter(board.matches),
  group: { name: 'cards', put: ({ keys }) => board.fits(props.column, keys) },
  overlay: CardOverlay,
  animateChanges: true,
  onBeforeDrop: (event) => (props.column.confirm && !event.sameList ? board.ask(event.items) : 'accept'),
  onDrop: (event) => {
    event.preventDefault();
    board.apply(event);
  },
});

Filters, and drops the board applies

The search and the people in the toolbar filter the cards, and every drag keeps working on what is shown. Each list reads its column through a getter, filtered, so the library never writes it: onDrop calls preventDefault() and hands e.result, the new filtered cards of every column the drop changed, to the board. The board puts them back into the places the shown cards had, and the cards the filters hide stay where they were.

store.ts
const merge = (all: Card[], shown: Card[]) => {
  const queue = [...shown];
  const merged: Card[] = [];
  for (const entry of all) {
    if (!matches(entry)) merged.push(entry);
    else if (queue.length) merged.push(queue.shift()!);
  }
  // Cards that came in from another column and found no place left: at the end.
  return [...merged, ...queue];
};

The cards in hand

CardOverlay.vue is the cards' overlay. The grabbed card lifts and tilts, the rest of a selection lies under it, built from DragPreview clones turned by their wrappers' transform, and a count sits on top. Under the cards, a hint says what letting go would do, from the overlay's over and accepted: Move 2 cards to In review, Complete, Delete, or In review is full · 2 max. Dropped into a column, each clone lands on its own slot; over the trash the pile shrinks, and dropped there the cards go in one after another.

CardOverlay.vue
const hint = computed(() => {
  if (props.status !== 'dragging' || !props.over) return null;
  const many = props.items.length > 1 ? ` ${props.items.length} cards` : '';
  if (props.over.id === 'trash') return { tone: 'danger', icon: 'trash', text: `Delete${many}` };
  const column = board.column(props.over.id);
  if (!column) return null;
  if (!props.accepted) return { tone: 'danger', icon: 'limit', text: `${column.title} is full · ${column.limit} max` };
  if (board.find(props.items[0]!.id)?.column.id === column.id) return null;
  if (column.confirm) return { tone: 'done', column, text: `Complete${many}` };
  return { tone: '', column, text: `Move${many} to ${column.title}` };
});

A column dragged by its header is the default clone, tilted with the rotate property while data-dnd-overlay reads dragging.

Done asks first

A drop into Done returns a promise from onBeforeDrop: the cards wait where they were let go while ConfirmDialog.vue asks. Move to Done answers 'accept', Cancel and Escape 'reject' (the cards fly back), and Keep dragging 'continue': the cards follow the pointer again, and the next click drops them. Once the cards have landed (onDragSettle), their check pops in Done and the sprint's progress moves.

Board.vue
useDnDMonitor({
  onDragSettle: (event) => {
    const target = event.over ? board.column(event.over.id) : undefined;
    if ('dropped' in event && target?.confirm && event.source?.list !== target.id) board.complete(event.accepted);
  },
});

Several cards, the trash and Undo

Selected cards travel together: drag one, and every selected card comes along, from any column. The selection bar moves them, deletes them, or clears the selection (Escape does too). The Trash.vue is a plain drop target that takes cards only; it deletes them from the board, as the Delete buttons do, so the notice can put each one back where it was.

Trash.vue
makeDroppable(el, {
  id: 'trash',
  accept: 'card',
  onDrop: (event) => {
    board.remove(event.active.ids);
    selection.delete(...event.active.ids);
  },
});

Moving without a drag

A card's panel has Status buttons, and the selection bar has a button per column. They move cards with @vue-dnd-kit/actions: moveItem plays a real keyboard drag of the card, a selected one bringing the selection. It is announced and animated like yours, and the column's limit and the Done question apply to it as to any drag.

store.ts
export function useMove() {
  const board = inject(BOARD)!;
  const { moveItem } = useDnDActions();
  return (ids: Key[], target: Column) => {
    if (!board.fits(target, ids)) return board.notify(`${target.title} is full: ${target.limit} cards at most`);
    void moveItem(ids[0]!, { list: target.id });
  };
}

Keyboard and screen readers

Only Space picks a card up, so Enter is free to open it; the instructions say so. The arrows move a card by slots, and across to the next column that takes it: a full one is skipped, and past the last column the next press carries the card on to the trash. The announcements name cards and columns instead of ids: "Picked up VDK-45, Haptics on Android tablets. Todo, position 6 of 6.", "In review is full: its limit is 2 cards."

index.vue
<DnDProvider
  :keyboard="{ keys: { start: ['Space'] } }"
  screen-reader-instructions="Space picks up a card or a column, the arrow keys move it, Space drops it, Escape cancels. Enter opens a card, X selects it."
  :announcements="announcements"
  :haptics="{ enter: true, refuse: true }"
>

Read more: Lists that share items · Deciding a drop · Multi-drag · The overlay · Programmatic drags · Accessibility