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 dragstart → dragover (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.jsonUsage
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
- Items live in exactly one zone. Each item carries a
zoneid; 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. - Native drag, native drop. Every chip is
draggable, every zone runs the canonicaldragover→preventDefaultopt-in — without it the browser silently rejects the drop and the drop handler never fires. The drag payload travels throughdataTransferso the component composes with custom MIME types and cross-window dragging when callers extend it. - Depth-counter hover state. A
useRefcounter ticks up ondragenterand down ondragleave. 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. - Keyboard fallback. Focus a chip and press
Space(orEnter) to pick it up;ArrowRight/ArrowLeft(orDown/Up) cycle the candidate zone;Enter(orSpaceagain) drops;Escapecancels. Thedata-stateattribute on the target zone is the same one the pointer drag uses, so styling stays unified. - Controlled vs uncontrolled. Pass
items+onItemsChangefor controlled; passdefaultItemsand skip both for uncontrolled.onMoveis fired after every successful move and receives the item id plus source and destination zones.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
zones | readonly DragDropZoneZone[] | required | Zone descriptors, in render order. |
items | readonly DragDropZoneItem[] | — | Controlled item list. Pair with onItemsChange. |
defaultItems | readonly DragDropZoneItem[] | — | Uncontrolled initial item list. |
onItemsChange | (items: DragDropZoneItem[]) => void | — | Fired with the next full item list after every move. |
onMove | (event: DragDropZoneMoveEvent) => void | — | Fired with { itemId, fromZone, toZone } after every move. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Layout direction for the zone strip. |
disabled | boolean | false | Disable every zone + item. |
emptyLabel | ReactNode | 'Drop items here' | Empty-state placeholder when a zone holds no items. |
zoneGap | string | '0.75rem' | Gap between zones (CSS length). |
minZoneWidth | string | '12rem' | Minimum width per zone in horizontal mode. |
aria-label | string | — | Accessible name for the outer container. |
className | string | — | Merged onto the rendered root <div>. |
DragDropZoneItem
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier — drives the React key and drag payload. |
label | ReactNode | Visible chip label. |
zone | string | Id of the zone this item currently belongs to. |
disabled | boolean | Disable dragging this single item. |
DragDropZoneZone
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier — matched against item.zone. |
label | ReactNode | Optional heading rendered above the zone. |
description | ReactNode | Optional helper text rendered below the label. |
disabled | boolean | Disable drops onto this zone. |
emptyLabel | ReactNode | Override 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 nativedraggableattribute. - A keyboard fallback is mandatory for WCAG 2.1.1: focus a chip, press
Space/Enterto pick up;ArrowLeft/ArrowRight(orUp/Down) cycle zones;Enter/Spacedrops;Escapecancels. - Hovered / keyboard-target zones expose a
data-stateattribute (hoverorkeyboard-target) so styling stays unified across pointer and keyboard paths. - Each zone renders as
role="region"witharia-labelledby(when a label is provided) andaria-describedby(when a description is provided). The outer container declaresrole="group"— passaria-labelfor 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/dragleaveare counted with auseRefdepth 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 (dragoverpreventDefaultand 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.