Drag Drop Zone

A drag-and-drop list where labelled items move between named zones. Built on the HTML5 drag-and-drop API — every drop is a real dragstartdragover (with preventDefault) → drop cycle, so the component composes with native file drops and custom MIME payloads. A focus + keyboard fallback (Space picks up, arrow keys cycle zones, Enter drops, Escape cancels) keeps the component operable without a pointer.

BacklogUnstarted
DoingIn progress
DoneShipped
Customize
Layout
3 zones (board)
horizontal

Installation

npx shadcn@latest add https://craftbits.dev/r/drag-drop-zone.json

Usage

Controlled — own the items array and react to moves:

import { useState } from "react";
import {
  DragDropZone,
  type DragDropZoneItem,
} from "@craft-bits/core";
 
const [items, setItems] = useState<DragDropZoneItem[]>([
  { id: "tokens", label: "Design tokens", zone: "backlog" },
  { id: "lint", label: "ESLint rules", zone: "doing" },
  { id: "scramble", label: "ScrambleHover", zone: "done" },
]);
 
<DragDropZone
  zones={[
    { id: "backlog", label: "Backlog" },
    { id: "doing", label: "Doing" },
    { id: "done", label: "Done" },
  ]}
  items={items}
  onItemsChange={setItems}
  aria-label="Task board"
/>

Uncontrolled — pass defaultItems and let the component own the state:

<DragDropZone
  zones={[
    { id: "todo", label: "To do" },
    { id: "done", label: "Done" },
  ]}
  defaultItems={[
    { id: "a", label: "Write docs", zone: "todo" },
    { id: "b", label: "Ship registry", zone: "todo" },
  ]}
  onMove={(event) => console.log(event)}
  aria-label="Tasks"
/>

Per-item disabled, per-zone disabled, vertical strip:

<DragDropZone
  orientation="vertical"
  zones={[
    { id: "inbox", label: "Inbox" },
    { id: "archive", label: "Archive", disabled: true },
  ]}
  defaultItems={[
    { id: "x", label: "Important", zone: "inbox" },
    { id: "y", label: "Pinned", zone: "inbox", disabled: true },
  ]}
  aria-label="Email triage"
/>

Understanding the component

  1. Items live in exactly one zone. Each item carries a zone id; the component groups them at render time so callers never own the per-zone array shape. Moving an item is a single field update on one object.
  2. Native drag, native drop. Every chip is draggable, every zone runs the canonical dragoverpreventDefault opt-in — without it the browser silently rejects the drop and the drop handler never fires. The drag payload travels through dataTransfer so the component composes with custom MIME types and cross-window dragging when callers extend it.
  3. Depth-counter hover state. A useRef counter ticks up on dragenter and down on dragleave. The zone is hovered when the counter is positive, which keeps the dashed border stable when the cursor crosses into a nested chip child — boolean hover would flicker false then true on every traversal.
  4. Keyboard fallback. Focus a chip and press Space (or Enter) to pick it up; ArrowRight / ArrowLeft (or Down / Up) cycle the candidate zone; Enter (or Space again) drops; Escape cancels. The data-state attribute on the target zone is the same one the pointer drag uses, so styling stays unified.
  5. Controlled vs uncontrolled. Pass items + onItemsChange for controlled; pass defaultItems and skip both for uncontrolled. onMove is fired after every successful move and receives the item id plus source and destination zones.

Props

PropTypeDefaultDescription
zonesreadonly DragDropZoneZone[]requiredZone descriptors, in render order.
itemsreadonly DragDropZoneItem[]Controlled item list. Pair with onItemsChange.
defaultItemsreadonly DragDropZoneItem[]Uncontrolled initial item list.
onItemsChange(items: DragDropZoneItem[]) => voidFired with the next full item list after every move.
onMove(event: DragDropZoneMoveEvent) => voidFired with { itemId, fromZone, toZone } after every move.
orientation'horizontal' | 'vertical''horizontal'Layout direction for the zone strip.
disabledbooleanfalseDisable every zone + item.
emptyLabelReactNode'Drop items here'Empty-state placeholder when a zone holds no items.
zoneGapstring'0.75rem'Gap between zones (CSS length).
minZoneWidthstring'12rem'Minimum width per zone in horizontal mode.
aria-labelstringAccessible name for the outer container.
classNamestringMerged onto the rendered root <div>.

DragDropZoneItem

FieldTypeDescription
idstringStable identifier — drives the React key and drag payload.
labelReactNodeVisible chip label.
zonestringId of the zone this item currently belongs to.
disabledbooleanDisable dragging this single item.

DragDropZoneZone

FieldTypeDescription
idstringStable identifier — matched against item.zone.
labelReactNodeOptional heading rendered above the zone.
descriptionReactNodeOptional helper text rendered below the label.
disabledbooleanDisable drops onto this zone.
emptyLabelReactNodeOverride the empty-state placeholder for this zone.

Accessibility

  • Each chip is a real <button> so it ships keyboard focus, :focus-visible, and screen-reader semantics for free. Drags are initiated via the native draggable attribute.
  • A keyboard fallback is mandatory for WCAG 2.1.1: focus a chip, press Space / Enter to pick up; ArrowLeft / ArrowRight (or Up / Down) cycle zones; Enter / Space drops; Escape cancels.
  • Hovered / keyboard-target zones expose a data-state attribute (hover or keyboard-target) so styling stays unified across pointer and keyboard paths.
  • Each zone renders as role="region" with aria-labelledby (when a label is provided) and aria-describedby (when a description is provided). The outer container declares role="group" — pass aria-label for the group's accessible name.
  • Disabled items drop from drag but remain focusable and announced; disabled zones reject drops but keep their items visible and operable.
  • Hover state survives nested chip children because dragenter / dragleave are counted with a useRef depth counter, not a boolean — no flicker when the cursor crosses into label text or icons.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/interaction/DragDropZone.tsx). The original lived inside an upload-component lesson as a phase that taught the two HTML5 drag-and-drop gotchas (dragover preventDefault and the depth-counter hover state). craft-bits keeps both gotchas baked in and generalises the surface into a typed item-list-by-zone primitive with controlled+uncontrolled state, per-item / per-zone disabled, a keyboard fallback, and a horizontal / vertical orientation switch.