For AI agents: the complete documentation index is available at https://gultyayev.github.io/ngx-virtual-dnd/llms.txt, the full documentation bundle is available at https://gultyayev.github.io/ngx-virtual-dnd/llms-full.txt, and this page is available as Markdown at https://gultyayev.github.io/ngx-virtual-dnd/guide/features/accessibility.md.

Accessibility

Every draggable can be moved with the keyboard, with nothing to enable. The exception is an item that is itself a text field, a select, a checkbox or an editable element, which needs a focusable drag handle (see below).

Keyboard

KeyNot draggingDuring a keyboard drag
TabMoves focus between draggablesCancels the drag
SpacePicks up the focused item (except in an item that is itself a text field, select or editable element; see below)Drops
Enter—Drops
↑ ↓—Moves the target position
← →—Moves to the neighbouring list in the same group, by on-screen position (disabled and hidden lists are skipped)
Escape—Cancels. Also cancels pointer drags.

A hidden list is one that is still in the page but not shown: display: none on it or an ancestor (an inactive tab, a column hidden by a media query), visibility: hidden, or a size of zero. Pointer drags skip hidden lists too: the drag falls through to the list underneath, if any, as it does for a disabled list.

Pressing Space in a control inside an item, such as a text field or a button, types into or clicks that control and does not pick the item up, unless the control is the drag handle. The same goes for an item that is itself a text field, textarea, select, checkbox or editable element: Space types into it, toggles it or opens it, and only Space on a focusable drag handle inside it picks it up (see Interactive children). So such an item needs one:

  • An input, textarea or select can't hold a handle. Make a row element the draggable instead and put the control inside it.
  • A [contenteditable] draggable without a focusable handle can't be moved with the keyboard. Give it a handle such as <span class="grip" contenteditable="false" tabindex="0" role="button" aria-label="Drag block">.

After a keyboard drop or cancel, focus returns to the draggable element itself. For an editable block that puts the caret in the block; press Tab to reach a grip inside it again.

Moving past the edge of the visible area scrolls the list to show where the item will land, so the keyboard reaches every item, not just the rendered ones. A list of plain @for rows scrolls the containers around it, nearest first.

Managed for you

  • tabindex="0" on draggables (-1 when disabled)
  • After a keyboard drag, focus returns to the moved item in its new position
  • aria-grabbed on draggables and aria-dropeffect="move" on droppables

aria-grabbed and aria-dropeffect are deprecated since ARIA 1.1, and most screen readers ignore them. They do not tell screen reader users that an item can be dragged, how to drag it, or where it is. Add instructions and announcements yourself, as described below.

Instructions

Nothing tells a screen reader user that Space picks an item up. Describe the keys once in a hidden element and point every draggable at it with aria-describedby. Screen readers read the description when the item gets focus:

<p id="drag-instructions" hidden>
  Press Space to pick up. Use the arrow keys to move, Space to drop and Escape to cancel.
</p>

<ng-template #taskTpl let-task>
  <div [vdndDraggable]="task.id" aria-describedby="drag-instructions">{{ task.title }}</div>
</ng-template>

Screen reader announcements

The library does not make announcements itself, so wording and translations stay in your hands. The events carry everything you need: (dragStart) for the pick-up, (placeholderMove) for every step and (dragEnd) for the result. Render the messages in a live region:

@Component({
  template: `
    <p id="drag-instructions" hidden>
      Press Space to pick up. Use the arrow keys to move, Space to drop and Escape to cancel.
    </p>

    <div vdndGroup="tasks">
      <ng-template #taskTpl let-task>
        <div
          [vdndDraggable]="task.id"
          [vdndDraggableData]="task"
          aria-describedby="drag-instructions"
          (dragStart)="onDragStart($event)"
          (dragEnd)="onDragEnd($event)"
        >
          {{ task.title }}
        </div>
      </ng-template>

      <vdnd-sortable-list
        droppableId="tasks"
        [items]="tasks()"
        [itemHeight]="48"
        [containerHeight]="400"
        [itemIdFn]="taskId"
        [itemTemplate]="taskTpl"
        (drop)="onDrop($event)"
        (placeholderMove)="onPlaceholderMove($event)"
      />
    </div>

    <div class="sr-only" aria-live="assertive">{{ announcement() }}</div>
  `,
})
export class TasksComponent {
  readonly #dragState = inject(DragStateService);
  readonly announcement = signal('');

  onDragStart(event: DragStartEvent): void {
    const task = event.data as Task;
    this.announcement.set(
      `Picked up ${task.title}, position ${event.sourceIndex + 1} of ${this.tasks().length}`,
    );
  }

  onPlaceholderMove(event: PlaceholderMoveEvent): void {
    // Pointer users see the placeholder move; announce keyboard steps only
    if (!this.#dragState.isKeyboardDrag()) return;
    this.announcement.set(`Position ${event.currentIndex + 1} of ${this.tasks().length}`);
  }

  onDragEnd(event: DragEndEvent): void {
    this.announcement.set(
      event.destinationIndex === null
        ? `Cancelled. Back at position ${event.sourceIndex + 1}`
        : `Dropped at position ${event.destinationIndex + 1}`,
    );
  }

  // tasks, taskId and onDrop as in the Quick start
}

.sr-only is the usual visually-hidden utility (position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);).

With multiple lists, also name the list in the step message: event.droppableId is the list the placeholder is in.

Touch

Touch drags work out of the box. If your lists also need to scroll by touch, add a short dragDelay so a quick swipe scrolls and a press-and-hold drags.