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.
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
| Key | Action |
|---|---|
| Space / Enter | Pick up the focused item; drop it. |
| Arrows | Move it: by slots in lists, by rows and depth in trees, by keyboard.step pixels in free drags. |
| Escape | Cancel 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.
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:
| Attribute | Value |
|---|---|
tabindex | 0, when the element isn't focusable already (a <div>; not an <a href> or a <button>). |
role | button, except on a <button>, which is one. |
aria-roledescription | draggable, or sortable on sortable items. |
aria-describedby | The provider's instructions, added next to the ids already there (a tooltip's survives). |
aria-disabled | true or false: whether the item is disabled (also by a disabled drag area). |
aria-pressed | true 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.
Links and buttons that drag
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:
<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
canDragturns that down and the browser follows the link. - A
<button>keeps its click too, and the library adds neitherrolenortabindex. - 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
<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".
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.
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)),
};
});
<DnDProvider :announcements="announcements" :screen-reader-instructions="texts.instructions">
context | At the start | While moving | At the end | On cancel |
|---|---|---|---|---|
position | Where it was picked up | Where it would land; none outside the lists | Where it landed | Where it went back to |
partner | A swap: the item exchanged with, and where it is now | The same, after the drop | ||
count, copy | How many items, and whether the drag copies |
| Event | Spoken when |
|---|---|
onDragStart | The item is picked up. |
onDragOver | The item moves to another place (a slot of a list, another target), once the drag rests for 500 ms. |
onDragEnd | It is dropped. The new order is already written. |
onDragCancel | The drag is cancelled, or the drop refused: the item went back. |
onDragResume | onBeforeDrop answered 'continue': the drag goes on (see deciding a drop). |
- A text replaces the event's announcement;
undefinedkeeps the default;''says nothing. Events you leave out keep their defaults, such as the positions of sortable lists. screenReaderInstructionsis the text every draggable points to witharia-describedby. Both props are reactive: the demo switches the language live.:announcements="false"silences the region, e.g. when you announce through your own.
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.
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):
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}.`;
};
<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:
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.