Skip to content

Drag & Drop

useDragDrop is a small, framework-agnostic composable wrapping HTML5 drag-and-drop with the same group / accept / canDrop semantics used by CoarListbox — but usable from any Vue component. It takes care of the fiddly bits (module-level payload registry, group matching, directional whitelists, source-side cleanup on accept) so your component only needs to wire events.

ts
import { useDragDrop } from '@cocoar/vue-ui';

Standalone example — custom Kanban board

The demo below is built from plain <div> columns + cards, using useDragDrop directly — no Listbox involved. Cards flow Backlog → In progress → Done; Backlog accepts no drops (not a target), Done accepts from everywhere, In progress only accepts from Backlog:

Backlog3
Research virtualization
med
Audit type coverage
low
Drop indicator UX
med
In progress1
Release Listbox v2
high
Done1
Ship Virtual List page
med
vue
<template>
  <div class="board">
    <KanbanColumn
      v-for="col in columns"
      :key="col.id"
      :title="col.title"
      :cards="col.cards"
      :column-id="col.id"
      :drag-accept="col.dragAccept"
      @items-add="(p) => onAdd(col.id, p)"
      @items-remove="(p) => onRemove(col.id, p)"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import KanbanColumn from './KanbanColumn.vue';

export interface Card { id: string; title: string; priority: 'low' | 'med' | 'high' }

const columns = ref([
  {
    id: 'backlog',
    title: 'Backlog',
    // Empty whitelist = accept nothing. Keeps Backlog as a source-only column.
    dragAccept: [] as string[] | undefined,
    cards: [
      { id: 'c1', title: 'Research virtualization', priority: 'med' },
      { id: 'c2', title: 'Audit type coverage', priority: 'low' },
      { id: 'c3', title: 'Drop indicator UX', priority: 'med' },
    ] as Card[],
  },
  {
    id: 'doing',
    title: 'In progress',
    dragAccept: ['backlog'],
    cards: [{ id: 'c4', title: 'Release Listbox v2', priority: 'high' }] as Card[],
  },
  {
    id: 'done',
    title: 'Done',
    dragAccept: ['backlog', 'doing'],
    cards: [{ id: 'c5', title: 'Ship Virtual List page', priority: 'med' }] as Card[],
  },
]);

function onAdd(colId: string, p: { items: readonly Card[] }) {
  const col = columns.value.find((c) => c.id === colId);
  if (!col) return;
  col.cards = [...col.cards, ...p.items];
}

function onRemove(colId: string, p: { items: readonly Card[] }) {
  const col = columns.value.find((c) => c.id === colId);
  if (!col) return;
  const removing = new Set(p.items.map((i) => i.id));
  col.cards = col.cards.filter((c) => !removing.has(c.id));
}
</script>

<style scoped>
.board { display: flex; gap: 16px; min-height: 340px; }
</style>

Each column is one component that wires the composable's startDrag / endDrag to its cards and onDragOver / onDragLeave / onDrop to itself:

vue
<script setup lang="ts">
import { useDragDrop } from '@cocoar/vue-ui'

const dnd = useDragDrop<Card>({
  dragId: () => props.columnId,
  dragGroup: 'kanban',
  dragAccept: () => props.dragAccept,  // whitelist of upstream columns
  onDropAccept: ({ items }) => emit('items-add', { items }),
  onItemsRemove: ({ items }) => emit('items-remove', { items }),
})
</script>

<template>
  <div
    :class="{ 'over': dnd.isDragOver.value }"
    @dragover="dnd.onDragOver"
    @dragleave="dnd.onDragLeave"
    @drop="dnd.onDrop($event)"
  >
    <div
      v-for="card in cards"
      draggable="true"
      @dragstart="dnd.startDrag($event, [card])"
      @dragend="dnd.endDrag($event)"
    >{{ card.title }}</div>
  </div>
</template>

Matching rules

A drop is accepted iff all of the following pass:

  1. dragGroup on source and target is equal (or both unset). This is the fast coarse-matching layer.
  2. dragAccept — if set on the target, the source's dragId must be in the list.
  3. canDrop — if provided on the target, it must return true for the incoming payload.

Self-drops (drag within the same surface) bypass the group check but still honour dragAccept and canDrop. They also skip onItemsRemove — the source of truth already holds the item.

Visual feedback: when any rule fails during dragover, the composable sets dropEffect = 'none' (cursor shows "not allowed") and leaves isDragOver false — wire that ref to a CSS class for accurate hover highlighting.

API

UseDragDropOptions<T>

OptionTypeDefaultDescription
dragIdMaybeRefOrGetter<string | undefined>Public identifier for this surface. Pair with another surface's dragAccept for directional flow.
dragGroupMaybeRefOrGetter<string | undefined>Shared name linking compatible surfaces. Only surfaces sharing a group exchange items.
dragAcceptMaybeRefOrGetter<string[] | undefined>Whitelist of source dragIds this surface accepts. Unset = accept any source in the same dragGroup.
canDrop(payload) => booleanRuntime drop validation. payload = { items, fromId, fromGroup, fromSelf }.
onDragStart(items) => voidCalled after startDrag registers a drag.
onDragEnd({ items, dropped }) => voidCalled on dragenddropped: true if a target consumed the payload.
onDropAccept(payload & { insertIndex }) => voidCalled on this surface when it accepts a drop. Update your source of truth here.
onItemsRemove({ items, toGroup }) => voidCalled on the source surface when another target consumed its payload — fires synchronously inside the target's drop. Update your source of truth here.

Return value

FieldTypeDescription
instanceIdstringStable per-instance identifier (auto-generated).
isDragOverRef<boolean>true while a compatible drag is hovering this surface. Wire to a CSS class.
isDraggingRef<boolean>true while this surface is the source of an in-flight drag.
startDrag(event, items) => booleanCall from @dragstart on a draggable element. Returns false for empty payloads.
endDrag(event) => voidCall from @dragend. Cleans up the session and fires onDragEnd.
onDragOver(event) => voidCall from the drop container's @dragover.
onDragLeave(event) => voidCall from @dragleave. Ignores events whose relatedTarget is still inside the container.
onDrop(event, ctx?) => voidCall from @drop. Optional ctx.insertIndex is forwarded to onDropAccept.

Patterns

Two-way exchange — both surfaces draggable + droppable + same dragGroup:

ts
useDragDrop({ dragGroup: 'items', onDropAccept, onItemsRemove })

One-way flow — give each source a unique dragId, whitelist on the target:

ts
// source (box1)
useDragDrop({ dragId: 'box1', dragGroup: 'flow' })
// target (box2) — only accepts from box1
useDragDrop({ dragGroup: 'flow', dragAccept: ['box1'], onDropAccept })

Capacity limits — reject in canDrop:

ts
useDragDrop({
  dragGroup: 'roles',
  canDrop: ({ items }) => admins.value.length + items.length <= 5,
  onDropAccept,
})

Integration with existing componentsCoarListbox uses this composable internally. If you're building a new component that needs the same drag semantics, reach for useDragDrop rather than reimplementing the registry.

Custom drag ghosts

The browser's default drag image is a semi-transparent snapshot of the dragged element. That looks fine for small items but turns into a giant faded blob for a large card or a nested tree node. Two tiny helpers attached to the same package give you a styled ghost next to the cursor without the boilerplate of cloning, off-screen mounting, and cleanup.

ts
import {
  setCoarDragImageFromElement,
  setCoarDragImageFromHtml,
} from '@cocoar/vue-ui';

function onDragStart(event: DragEvent) {
  setCoarDragImageFromElement(event, event.currentTarget as HTMLElement);
}

The helper clones the source element, sizes the clone to match the source's bounding box, mounts it off-screen (horizontally, because Chromium skips rendering elements that are entirely outside the viewport — and an unrendered ghost captures as an empty bitmap), calls dataTransfer.setDragImage, and removes the clone on the next macrotask so the browser has time to rasterise it.

For a free-form ghost that doesn't mirror an existing element, use the HTML variant:

ts
setCoarDragImageFromHtml(event, `
  <div style="padding: 6px 10px; font-size: 12px;">
    Moving 3 items
  </div>
`);

API

setCoarDragImageFromElement(event, source, options?)

ArgumentTypeDescription
eventDragEventThe dragstart event. Called synchronously inside the handler.
sourceHTMLElementElement to clone as the ghost. The live element is not visually disturbed.
optionsCoarDragImageOptionsOptional styling overrides. See below.

setCoarDragImageFromHtml(event, html, options?)

Same contract, but builds the ghost from a raw HTML string instead of cloning an element. Useful for "drag summary" previews (e.g. "Moving 3 items") that don't correspond to a single DOM node.

CoarDragImageOptions

OptionTypeDefaultDescription
offsetXnumber12Cursor offset within the ghost, in px.
offsetYnumber12Cursor offset within the ghost, in px.
classNamestringCSS class applied to the generated wrapper so consumers can theme the ghost.
stylePartial<CSSStyleDeclaration>Inline styles merged onto the wrapper. Prefer className when possible.
applyDefaultStylebooleantrueApply the default rounded-corner, drop-shadow, 0.9 opacity treatment. Set to false when the caller handles all styling via className.

Released under the Apache-2.0 License.