Phoundry UI

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

PropTypeDefaultDescription
id requiredstringUnique 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 booleanIf true, items can be dragged out but this list does not accept reorder/drops.
showZoneIndicator booleantrueDashed outline on list hover during drag; set false for compact chrome (tabs, sidebars).
onReorder (fromIndex, toIndex) => voidSame-list reorder after drop.
onReceive (item, fromListId, toIndex) => voidCross-list: item landed in this list (pair with source `onRemove`).
onRemove (item) => voidCross-list: item left this list toward another registered list.

DndItemOptions

PropTypeDefaultDescription
id requiredstring | numberStable item id (`data-dnd-item`).
data DndItemDataPayload passed to receivers (defaults to `{ id }`).
disabled booleanIf true, item does not start a drag.
dragHandle stringCSS selector for the drag handle within the item. Omit to drag from the whole item.
dragThreshold number5Pointer movement (px) before a drag starts.
onClickWithoutDrag () => voidCalled on pointer up when movement stayed below dragThreshold (also when disabled).
onDragStart () => voidCalled once when dragThreshold is crossed and a drag begins.

Usage notes

  • Mount DndGhost and DndIndicator once per layout surface that uses this kit (the docs page mounts them below the examples).
  • Attach dndList on the scroll/container that wraps draggable rows; attach dndItem on 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 dndItem rules.
  • KanbanBoard composes these attachments for multi-column cards.