Docs/Guides

Accessibility

Keyboard drags, screen reader announcements and ARIA attributes are built in. Here is what they do, how to word them in any language, and how to offer moves without dragging.

Everything on this page is on by default. Tab to an agenda item, press Space, move it with the arrow keys and press Space again, or Escape to put it back. The panel shows what a screen reader is told, in the language you pick.

Live

Loading the demo…

<script setup lang="ts">
import { computed, ref } from 'vue';
import { DnDProvider, type Announcements, type Key } from '@vue-dnd-kit/core';
import Agenda from './Agenda.vue';
import { LANGUAGES } from './texts';

const agenda = ref([
  { id: 'welcome', title: 'Welcome', minutes: 5 },
  { id: 'roadmap', title: 'Roadmap', minutes: 15 },
  { id: 'demo', title: 'Live demo', minutes: 10 },
  { id: 'questions', title: 'Questions', minutes: 10 },
]);
const lang = ref('en');
const texts = computed(() => LANGUAGES[lang.value]!);

// Every text handed to the live region, newest first.
const spoken = ref<{ id: number; text: string }[]>([]);
let count = 0;
const say = (text: string) => {
  if (spoken.value[0]?.text !== text) spoken.value = [{ id: ++count, text }, ...spoken.value].slice(0, 4);
  return text;
};

const titleOf = (id: Key) => agenda.value.find((item) => item.id === id)?.title ?? String(id);

// `position` is where the built-in texts say the item is: picked up at, moving to, dropped
// at, back at. `index` counts from 0.
const announcements = computed<Partial<Announcements>>(() => {
  const t = texts.value;
  return {
    onDragStart: ({ active }, { position }) =>
      position && say(t.start(titleOf(active.id), position.index + 1, position.length)),
    onDragOver: ({ active }, { position }) =>
      position && say(t.move(titleOf(active.id), position.index + 1, position.length)),
    onDragEnd: ({ active }, { position }) =>
      position && say(t.drop(titleOf(active.id), position.index + 1, position.length)),
    onDragCancel: ({ active }, { position }) => position && say(t.cancel(titleOf(active.id), position.index + 1)),
    onDragResume: ({ active }) => say(t.resume(titleOf(active.id))),
  };
});
</script>

<template>
  <!-- `lang` around the provider: its live region renders inside, and a screen reader
       speaks the announcements in their own language. -->
  <div :lang="lang">
  <DnDProvider :announcements="announcements" :screen-reader-instructions="texts.instructions">
    <div class="demo">
      <div class="languages" role="radiogroup" aria-label="Language of the announcements">
        <label v-for="(language, code) in LANGUAGES" :key="code" :class="{ on: lang === code }">
          <input v-model="lang" type="radio" :value="code" />
          {{ language.name }}
        </label>
      </div>
      <div class="layout">
        <Agenda v-model="agenda" />
        <section class="reader" aria-label="Announcements">
          <h4>Announcements</h4>
          <p class="instructions"><span>aria-describedby</span>{{ texts.instructions }}</p>
          <ol class="spoken">
            <li v-for="line in spoken" :key="line.id">{{ line.text }}</li>
            <li v-if="!spoken.length" class="empty">Tab to an item and press Space</li>
          </ol>
        </section>
      </div>
    </div>
  </DnDProvider>
  </div>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.languages {
  display: inline-flex;
  flex-wrap: wrap;
  justify-content: center;
  padding: 3px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.languages label {
  padding: 5px 10px;
  border-radius: 6px;
  font-size: 12px;
  cursor: pointer;
}

.languages label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.languages label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.languages input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.layout {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 18px;
  width: 100%;
}

.reader {
  box-sizing: border-box;
  width: min(100%, 270px);
  padding: 12px 14px;
  border-radius: 12px;
  background: color-mix(in srgb, currentColor 5%, transparent);
  font: 12px/1.5 ui-monospace, Menlo, Consolas, monospace;
}

h4 {
  margin: 0 0 8px;
  font: inherit;
  font-weight: 600;
  opacity: 0.55;
}

.instructions {
  margin: 0 0 10px;
  padding-bottom: 10px;
  border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
  font-size: 11px;
  opacity: 0.7;
}

.instructions span {
  display: block;
  margin-bottom: 2px;
  font-size: 10px;
  opacity: 0.7;
}

.spoken {
  min-height: 96px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.spoken li + li {
  margin-top: 4px;
  opacity: 0.5;
}

.spoken li:first-child {
  color: #42b883;
}

.spoken li.empty {
  color: inherit;
  opacity: 0.45;
}
</style>

What is usually left to you: readable texts for the items, and buttons that move them for people who can't drag at all.

The keyboard

KeyAction
Space / EnterPick up the focused item; drop it.
ArrowsMove it: by slots in lists, by rows and depth in trees, by keyboard.step pixels in free drags.
EscapeCancel the drag — pointer drags too.
  • In a list, an arrow toward another list of the same group moves the item there.
  • In a free drag, Shift makes the steps bigger. makeDraggable(el, { keyboardMode: 'droppables' }) makes the arrows jump between the targets that accept the item instead.
  • A keyboard drag starts from the focused draggable, or its handle — never from a button or a field inside it, so those keep their own keys.
  • Keyboard drags scroll the item into view instead of auto-scrolling at the edges.
  • After the drop, focus goes back to the item, even when its row was re-rendered in another list.

Other keys

<DnDProvider :keyboard="{ keys: { start: ['KeyD'], drop: ['KeyD', 'Enter'], cancel: ['Escape'] }, step: 10 }">

Bindings match event.code ('Space', 'KeyD') or event.key ('Enter'). step is the pixels per arrow press in free drags (25 by default). :keyboard="false" turns keyboard drags off, and Escape still cancels any drag. When you change the keys, change the screen-reader-instructions too.

ARIA attributes

Focus each shortcut, or turn the attributes off, and watch what the library sets. Drag one to see aria-pressed come and go on the button.

Live

Loading the demo…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Shortcuts from './Shortcuts.vue';

const attributes = ref(true);
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <label class="switch">
        <input v-model="attributes" type="checkbox" />
        attributes: {{ attributes }}
      </label>
      <Shortcuts :attributes="attributes" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.switch {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.switch:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.switch input {
  accent-color: #42b883;
}
</style>

Every draggable and sortable item gets these, unless you set them yourself:

AttributeValue
tabindex0, when the element isn't focusable already (a <div>; not an <a href> or a <button>).
rolebutton, except on a <button>, which is one.
aria-roledescriptiondraggable, or sortable on sortable items.
aria-describedbyThe provider's instructions, added next to the ids already there (a tooltip's survives).
aria-disabledtrue or false: whether the item is disabled (also by a disabled drag area).
aria-pressedtrue while it is dragged, on elements with the button role.

attributes: false on an item removes what the library had set, live. The panel reads the attributes back: aria-describedby points to the provider's instructions, which a screen reader reads after the item's name.

aria-roledescription replaces the name of the role: a screen reader says "Inbox, sortable" instead of "Inbox, button". For an element whose role matters, set your own; the library leaves it alone:

Shortcut.vue
<script setup lang="ts">
makeSortableItem(el, {
  item: () => props.shortcut,
  // Enter follows the link: only Space picks it up.
  canDrag: (event) => !('key' in event && event.key === 'Enter'),
});
</script>

<template>
  <a ref="el" :href="shortcut.href" role="link" aria-roledescription="draggable link">
    {{ shortcut.title }}
  </a>
</template>
  • A link keeps working: a click opens it, but the click that ends a drag never reaches it. Enter would start a drag, so canDrag turns that down and the browser follows the link.
  • A <button> keeps its click too, and the library adds neither role nor tabindex.
  • Buttons, links and fields inside an item don't start pointer drags, so a row can hold its own actions.

Handles: make them real buttons

Track.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';

const props = defineProps<{ track: { id: string; title: string } }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.track, handle: '.grip' });
</script>

<template>
  <li ref="el" class="track">
    {{ track.title }}
    <button type="button" class="grip" :aria-label="`Drag ${track.title}`">⠿</button>
  </li>
</template>

With a handle, the attributes go on the handle, and keyboard drags start from it (the Archive shortcut above). A <button> is focusable and a button already, so it needs no tabindex or role. Give it an aria-label: a grip icon has no name of its own, and "Drag Midnight Drive" says more than "button".

The handle keeps the focus for the whole keyboard drag, while its row is the slot. Empty the slot with opacity: 0 rather than visibility: hidden or display: none on the handle: a hidden element drops the focus. The library gives it back after the drop, but a screen reader may say "body" in between.

Announcements

A live region in the provider speaks every pickup, move, drop and cancel. Sortable lists say where the item is: "Picked up item t2 at position 2 of 5 in list playlist." Moves are spoken once the drag has rested for 500 ms, so a fast pointer drag isn't read slot by slot. The demo's panel logs every text the provider is given, including the moves a pointer drag passes on its way.

The built-in texts are in English, and name things by their ids unless you give them a label. Items, lists, trees and drop targets all take one:

makeSortableList(list, { items: tracks, label: 'Playlist' });
makeSortableItem(el, { item: () => props.track, label: () => props.track.title });
makeDroppable(bin, { label: 'Trash', onDrop });

"Picked up Blue in Green at position 2 of 5 in Playlist." The labels reach your own texts and monitors too: active.label, over.label, each of active.items, and in the context position.label and partner.label.

For another language, or other words, write the texts yourself, as the agenda does. Each text gets the event and a context: the position the built-in text would speak (list, index from 0, length, the list's label), and count, copy and a swap's partner.

index.vue
const titleOf = (id: Key) => agenda.value.find((item) => item.id === id)?.title ?? String(id);

const announcements = computed<Partial<Announcements>>(() => {
  const t = LANGUAGES[lang.value]!;
  return {
    onDragStart: ({ active }, { position }) => position && t.start(titleOf(active.id), position.index + 1, position.length),
    onDragOver: ({ active }, { position }) => position && t.move(titleOf(active.id), position.index + 1, position.length),
    onDragEnd: ({ active }, { position }) => position && t.drop(titleOf(active.id), position.index + 1, position.length),
    onDragCancel: ({ active }, { position }) => position && t.cancel(titleOf(active.id), position.index + 1),
    onDragResume: ({ active }) => t.resume(titleOf(active.id)),
  };
});
index.vue
<DnDProvider :announcements="announcements" :screen-reader-instructions="texts.instructions">
contextAt the startWhile movingAt the endOn cancel
positionWhere it was picked upWhere it would land; none outside the listsWhere it landedWhere it went back to
partnerA swap: the item exchanged with, and where it is nowThe same, after the drop
count, copyHow many items, and whether the drag copies
EventSpoken when
onDragStartThe item is picked up.
onDragOverThe item moves to another place (a slot of a list, another target), once the drag rests for 500 ms.
onDragEndIt is dropped. The new order is already written.
onDragCancelThe drag is cancelled, or the drop refused: the item went back.
onDragResumeonBeforeDrop answered 'continue': the drag goes on (see deciding a drop).
  • A text replaces the event's announcement; undefined keeps the default; '' says nothing. Events you leave out keep their defaults, such as the positions of sortable lists.
  • screenReaderInstructions is the text every draggable points to with aria-describedby. Both props are reactive: the demo switches the language live.
  • :announcements="false" silences the region, e.g. when you announce through your own.
For another language, give all five texts and the instructions: each one you leave out is spoken in English, sortable positions included. Set the page's lang too, so the screen reader picks the right voice.

Moving without dragging

Some people can't drag at all. WCAG 2.5.7 asks for a way to move items with single clicks. Use the arrows and the check on each row: they move the books without a drag, and the focus stays where you are.

Live

Loading the demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import ReadingList from './ReadingList.vue';
</script>

<template>
  <DnDProvider>
    <ReadingList />
  </DnDProvider>
</template>

A button changes the data; the list draws the move. With animateChanges on the list, the rows slide as they do in a drag (and stay still for users who ask for reduced motion):

ReadingList.vue
const { items } = makeSortableList(list, { id: 'reading', items: books, animateChanges: true });

// Buttons change the data, not a drag: the provider says nothing, so this region does.
const message = ref('');

const move = (from: number, to: number) => {
  const next = [...books.value];
  const [book] = next.splice(from, 1);
  next.splice(to, 0, book!);
  books.value = next;
  message.value = `${book!.title} moved to position ${to + 1} of ${next.length}.`;
};
ReadingList.vue
<p class="status" role="status">{{ message }}</p>

Keep the focus

Moving an element in the DOM takes the focus away from it, so the button that was just pressed loses it. Give it back once the row has moved:

BookRow.vue
const move = async (direction: 'up' | 'down') => {
  emit('move', direction === 'up' ? props.index - 1 : props.index + 1);
  await nextTick();
  // Back on the same button, or on the other one when this one is now disabled.
  const same = direction === 'up' ? up.value : down.value;
  const other = direction === 'up' ? down.value : up.value;
  (same?.disabled ? other : same)?.focus();
};

A row that goes away (the check marks a book as read) takes the focus with it: move it to the next row, or the last one. Drags need none of this: the library puts the focus back on the dropped item itself, even when it re-renders in another list.

@vue-dnd-kit/actions turns each button into a real keyboard drag instead: moveItem(id, { index }) is announced, goes through onBeforeDrop and onDrop, animates, and keeps the focus on the item. See programmatic drags.

Touch

Touch drags start after a 200 ms long press. Moving more than 5 px first scrolls the page instead, so lists stay scrollable. Tune both with the provider's activation, and draw a long-press ring from useDnD().activation.progress: see the provider. A touch drag vibrates briefly when it starts; :haptics="false" turns that off.

Reduced motion

With prefers-reduced-motion: reduce, the library's own animations don't run: items jump to their places while sorting, the overlay doesn't fly on drop, and animateChanges changes appear at once. Your own animation functions still run, with reducedMotion: true, so you decide: skip them, or keep a short fade. See animations.