Drag & Drop
Svelte 5 attachment-based sortable lists with a shared ghost and drop indicator. Cross-list moves use onRemove / onReceive. OS file drops use the DropZone component.
import { dndList, dndItem, DndGhost, DndIndicator } from 'phoundry-ui'; Sortable list
First item
Second item
Third item
Fourth item
Fifth item
Drag to reorder. Import bundled styles (phoundry-ui/styles/components.css) so dnd.css applies (grab cursor, dragging opacity, zone hints).
Show code
<script lang="ts">
import {
dndList,
dndItem,
DndGhost,
DndIndicator,
} from 'phoundry-ui';
interface Row extends Record<string, unknown> {
id: string;
label: string;
}
let items = $state<Row[]>([
{ id: '1', label: 'First' },
{ id: '2', label: 'Second' },
]);
const list = $derived(
dndList({
id: 'my-list',
axis: 'y',
showZoneIndicator: false,
onReorder(fromIndex, toIndex) {
const moved = items.splice(fromIndex, 1)[0];
items.splice(toIndex, 0, moved);
items = [...items];
},
}),
);
</script>
<div {@attach list} class="space-y-1">
{#each items as item (item.id)}
<div
{@attach dndItem({ id: item.id, data: item })}
class="rounded border px-3 py-2 text-xs"
>
{item.label}
</div>
{/each}
</div>
<DndGhost />
<DndIndicator />Cross-list transfer
List A
Alpha
Beta
List B
Gamma
Delta
Show code
// List A
const listA = $derived(dndList({
id: 'list-a',
acceptFrom: ['list-b'],
onReorder: ...,
onRemove(item) { /* remove from A, set pending */ },
onReceive(item, fromId, toIndex) { /* insert into A */ },
}));
// List B — mirror with acceptFrom: ['list-a']OS file drops
Use DropZone from phoundry-ui for file pick / drag-in uploads — it is separate from list reordering DnD.
DndListOptions
| Prop | Type | Default | Description |
|---|---|---|---|
| id required | string | — | Unique list id (also `data-dnd-list`). |
| axis | 'x' | 'y' | 'y' | Primary axis for insertion hints (horizontal tabs vs vertical lists). |
| acceptFrom | string[] | — | Source list ids allowed to drop here. Omit for unrestricted cross-list drops. |
| sourceOnly | boolean | — | If true, items can be dragged out but this list does not accept reorder/drops. |
| showZoneIndicator | boolean | true | Dashed outline on list hover during drag; set false for compact chrome (tabs, sidebars). |
| onReorder | (fromIndex, toIndex) => void | — | Same-list reorder after drop. |
| onReceive | (item, fromListId, toIndex) => void | — | Cross-list: item landed in this list (pair with source `onRemove`). |
| onRemove | (item) => void | — | Cross-list: item left this list toward another registered list. |
DndItemOptions
| Prop | Type | Default | Description |
|---|---|---|---|
| id required | string | number | — | Stable item id (`data-dnd-item`). |
| data | DndItemData | — | Payload passed to receivers (defaults to `{ id }`). |
| disabled | boolean | — | If true, item does not start a drag. |
| dragHandle | string | — | CSS selector for the drag handle within the item. Omit to drag from the whole item. |
| dragThreshold | number | 5 | Pointer movement (px) before a drag starts. |
| onClickWithoutDrag | () => void | — | Called on pointer up when movement stayed below dragThreshold (also when disabled). |
| onDragStart | () => void | — | Called once when dragThreshold is crossed and a drag begins. |
Usage notes
- Mount
DndGhostandDndIndicatoronce per layout surface that uses this kit (the docs page mounts them below the examples). - Attach
dndListon the scroll/container that wraps draggable rows; attachdndItemon each row (up to three levels of wrapper depth are considered when resolving drop slots). - Interactive controls inside a row (buttons, inputs, links) block drag initiation unless wrapped outside the hit-tested subtree per
dndItemrules. KanbanBoardcomposes these attachments for multi-column cards.