Sortable (DnD)
Drag-and-drop sortable lists built on
svelte-dnd-action. Supports
single-list reordering, grouped cross-list transfer, handle-only dragging,
animated placement previews, and a bindable Svelte-friendly list API.
Import
import { SortableList, SortableGroup } from '@atom-forge/ui';
Items must have a unique id: string | number field.
SortableList
SortableList is the primary API. In the common case, use bind:items; the
component writes the final order back after drop.
Props
| Prop |
Type |
Default |
Description |
items |
T[] |
[] |
Bindable array of items. Updated on drop. |
id |
string |
'default-list' |
Identifier for this list. Use unique ids inside a SortableGroup. |
item |
Snippet<[T]> |
— |
Snippet rendered for each item. |
orientation |
'vertical' | 'horizontal' | 'grid' |
'vertical' |
Controls list direction and default placement indicator orientation. |
grabbedClass |
string |
'shadow-xl opacity-95 scale-[1.01]' |
Tailwind classes added to the actively dragged element. |
draggingClass |
string |
'opacity-35' |
Tailwind classes applied to the source item while it is dragged. |
dropIndicatorClass |
string |
— |
Tailwind classes added to the shadow/placement item wrapper. Ignored when not needed by the chosen indicator. |
dragHandleSelector |
string |
— |
CSS selector for a handle inside each item. Drag starts only from matching elements. |
flipDurationMs |
number |
160 |
Duration for Svelte animate:flip list movement. |
onchange |
(items, detail) => void |
— |
Called after a successful reorder or transfer, only when item order changed. |
dropIndicator |
Snippet<[T | undefined]> |
— |
Custom placement indicator snippet. Rendered for the temporary shadow item. |
empty |
{ icon, title, description? } |
— |
Shown via EmptyState when the list is empty. |
class |
string |
— |
Added to the list wrapper <div>. |
preservePreviewSize |
boolean |
true |
Compatibility prop from the previous implementation. Currently retained for API stability. |
previewOffset |
(args) => {x, y} |
— |
Compatibility prop from the previous implementation. Currently retained for API stability. |
Change detail
onchange receives the next items array and a detail object:
type SortableChangeDetail<T extends { id: string | number }> = {
items: T[];
previousItems: T[];
item: T | undefined;
itemId: string | number;
fromIndex: number;
toIndex: number;
trigger: string;
source: string;
};
Use bind:items when the component should own the local reorder. Add
onchange when the parent also needs to persist, validate, or react to the
change.
Basic usage
<script lang="ts">
import { SortableList } from '@atom-forge/ui';
let tasks = $state([
{ id: 1, title: 'Design' },
{ id: 2, title: 'Develop' },
{ id: 3, title: 'Test' },
]);
</script>
<SortableList bind:items={tasks} class="gap-2">
{#snippet item(task)}
<div class="rounded border border-frame bg-control p-3">
{task.title}
</div>
{/snippet}
</SortableList>
React to changes
<SortableList
bind:items={tasks}
onchange={(nextTasks, detail) => {
console.log('moved', detail.itemId, detail.fromIndex, detail.toIndex);
saveOrder(nextTasks.map(task => task.id));
}}
>
{#snippet item(task)}
<div class="rounded border border-frame bg-control p-3">
{task.title}
</div>
{/snippet}
</SortableList>
onchange is fired after finalize/drop, not on every hover movement.
Drag handle
Only trigger drag from a specific element inside the item:
<SortableList bind:items={tasks} dragHandleSelector="[data-handle]" class="gap-2">
{#snippet item(task)}
<div class="flex items-center gap-2 rounded border border-frame bg-control p-3">
<GripVertical data-handle class="cursor-grab text-muted-contrast" />
<span>{task.title}</span>
</div>
{/snippet}
</SortableList>
The selector can target regular elements or SVG/icon elements.
Drag state styling
<SortableList
bind:items={tasks}
grabbedClass="rounded-md bg-surface shadow-xl ring-2 ring-accent/40 scale-[1.02]"
draggingClass="opacity-30 blur-[1px]"
>
{#snippet item(task)}...{/snippet}
</SortableList>
Custom drop indicator
<SortableList bind:items={tasks} class="gap-2">
{#snippet item(task)}...{/snippet}
{#snippet dropIndicator(draggingItem)}
<div class="rounded-md border border-dotted border-accent/60 bg-accent/5 p-2">
{draggingItem?.title}
</div>
{/snippet}
</SortableList>
Without a custom snippet, the temporary shadow item uses the normal item
snippet. Use dropIndicator when the placement preview should be lighter than
the real item, or when the list needs a line/dot-style insertion marker.
SortableGroup
Wrap multiple SortableLists in a SortableGroup to allow items to move
between lists. Each list should have a unique id.
<script lang="ts">
import { SortableList, SortableGroup } from '@atom-forge/ui';
let todo = $state([{ id: 1, title: 'Plan' }, { id: 2, title: 'Design' }]);
let doing = $state([{ id: 3, title: 'Develop' }]);
let done = $state([{ id: 4, title: 'Deploy' }]);
</script>
<SortableGroup class="flex gap-4">
<SortableList id="todo" bind:items={todo} class="min-w-56 gap-2">
{#snippet item(task)}...{/snippet}
</SortableList>
<SortableList id="doing" bind:items={doing} class="min-w-56 gap-2">
{#snippet item(task)}...{/snippet}
</SortableList>
<SortableList id="done" bind:items={done} class="min-w-56 gap-2">
{#snippet item(task)}...{/snippet}
</SortableList>
</SortableGroup>
SortableGroup props
| Prop |
Type |
Default |
Description |
class |
string |
— |
Added to the wrapper <div>. |
rules |
GroupRules |
— |
Legacy compatibility prop. The current svelte-dnd-action implementation does not enforce per-list accept rules yet. |
typeField |
string |
'type' |
Legacy compatibility prop used by the previous rule engine. |
Grouped lists share an internal DnD type, so items can transfer between lists in
the same group and remain isolated from lists outside that group.
Legacy low-level exports
DropIndicator, DropSlot, and dnd are still exported for compatibility with
older custom DnD surfaces. New sortable list UIs should prefer SortableList
and SortableGroup.
import { DropIndicator, DropSlot, dnd } from '@atom-forge/ui';
The low-level dnd helper still wraps @atlaskit/pragmatic-drag-and-drop.
It is separate from the current SortableList implementation, which uses
svelte-dnd-action.
When to use
Use for flat list reorder, grid reorder, or cross-list transfer with committed array updates.
Alternatives
Use Tree for validated hierarchical moves or Organizer for coordinate-based dragging.
Setup and limitations
Bind items and supply stable unique IDs across every zone that can exchange items. Independent lists also need distinct list IDs: ungrouped lists with the same ID share a DnD type. SortableGroup is the only context provider needed, not Root. Hover/consider changes update an internal preview; finalize assigns items before calling onchange, which fires only if membership/order changed. During transfer, fromIndex or toIndex may be -1 for a list the item enters/leaves. Callbacks are notifications, not cancellable validation hooks; legacy rules/typeField and preview props do not enforce restrictions in this implementation. There is no dedicated disabled prop. Persist on onchange and handle application-level rejection explicitly.
Sortable (DnD)
Drag-and-drop sortable lists built on
svelte-dnd-action. Supports single-list reordering, grouped cross-list transfer, handle-only dragging, animated placement previews, and a bindable Svelte-friendly list API.Import
import { SortableList, SortableGroup } from '@atom-forge/ui';Items must have a unique
id: string | numberfield.SortableList
SortableListis the primary API. In the common case, usebind:items; the component writes the final order back after drop.Props
itemsT[][]idstring'default-list'SortableGroup.itemSnippet<[T]>orientation'vertical' | 'horizontal' | 'grid''vertical'grabbedClassstring'shadow-xl opacity-95 scale-[1.01]'draggingClassstring'opacity-35'dropIndicatorClassstringdragHandleSelectorstringflipDurationMsnumber160animate:fliplist movement.onchange(items, detail) => voiddropIndicatorSnippet<[T | undefined]>empty{ icon, title, description? }EmptyStatewhen the list is empty.classstring<div>.preservePreviewSizebooleantruepreviewOffset(args) => {x, y}Change detail
onchangereceives the next items array and a detail object:type SortableChangeDetail<T extends { id: string | number }> = { items: T[]; previousItems: T[]; item: T | undefined; itemId: string | number; fromIndex: number; toIndex: number; trigger: string; source: string; };Use
bind:itemswhen the component should own the local reorder. Addonchangewhen the parent also needs to persist, validate, or react to the change.Basic usage
<script lang="ts"> import { SortableList } from '@atom-forge/ui'; let tasks = $state([ { id: 1, title: 'Design' }, { id: 2, title: 'Develop' }, { id: 3, title: 'Test' }, ]); </script> <SortableList bind:items={tasks} class="gap-2"> {#snippet item(task)} <div class="rounded border border-frame bg-control p-3"> {task.title} </div> {/snippet} </SortableList>React to changes
<SortableList bind:items={tasks} onchange={(nextTasks, detail) => { console.log('moved', detail.itemId, detail.fromIndex, detail.toIndex); saveOrder(nextTasks.map(task => task.id)); }} > {#snippet item(task)} <div class="rounded border border-frame bg-control p-3"> {task.title} </div> {/snippet} </SortableList>onchangeis fired after finalize/drop, not on every hover movement.Drag handle
Only trigger drag from a specific element inside the item:
<SortableList bind:items={tasks} dragHandleSelector="[data-handle]" class="gap-2"> {#snippet item(task)} <div class="flex items-center gap-2 rounded border border-frame bg-control p-3"> <GripVertical data-handle class="cursor-grab text-muted-contrast" /> <span>{task.title}</span> </div> {/snippet} </SortableList>The selector can target regular elements or SVG/icon elements.
Drag state styling
<SortableList bind:items={tasks} grabbedClass="rounded-md bg-surface shadow-xl ring-2 ring-accent/40 scale-[1.02]" draggingClass="opacity-30 blur-[1px]" > {#snippet item(task)}...{/snippet} </SortableList>Custom drop indicator
<SortableList bind:items={tasks} class="gap-2"> {#snippet item(task)}...{/snippet} {#snippet dropIndicator(draggingItem)} <div class="rounded-md border border-dotted border-accent/60 bg-accent/5 p-2"> {draggingItem?.title} </div> {/snippet} </SortableList>Without a custom snippet, the temporary shadow item uses the normal
itemsnippet. UsedropIndicatorwhen the placement preview should be lighter than the real item, or when the list needs a line/dot-style insertion marker.SortableGroup
Wrap multiple
SortableLists in aSortableGroupto allow items to move between lists. Each list should have a uniqueid.<script lang="ts"> import { SortableList, SortableGroup } from '@atom-forge/ui'; let todo = $state([{ id: 1, title: 'Plan' }, { id: 2, title: 'Design' }]); let doing = $state([{ id: 3, title: 'Develop' }]); let done = $state([{ id: 4, title: 'Deploy' }]); </script> <SortableGroup class="flex gap-4"> <SortableList id="todo" bind:items={todo} class="min-w-56 gap-2"> {#snippet item(task)}...{/snippet} </SortableList> <SortableList id="doing" bind:items={doing} class="min-w-56 gap-2"> {#snippet item(task)}...{/snippet} </SortableList> <SortableList id="done" bind:items={done} class="min-w-56 gap-2"> {#snippet item(task)}...{/snippet} </SortableList> </SortableGroup>SortableGroup props
classstring<div>.rulesGroupRulessvelte-dnd-actionimplementation does not enforce per-list accept rules yet.typeFieldstring'type'Grouped lists share an internal DnD type, so items can transfer between lists in the same group and remain isolated from lists outside that group.
Legacy low-level exports
DropIndicator,DropSlot, anddndare still exported for compatibility with older custom DnD surfaces. New sortable list UIs should preferSortableListandSortableGroup.import { DropIndicator, DropSlot, dnd } from '@atom-forge/ui';The low-level
dndhelper still wraps@atlaskit/pragmatic-drag-and-drop. It is separate from the currentSortableListimplementation, which usessvelte-dnd-action.When to use
Use for flat list reorder, grid reorder, or cross-list transfer with committed array updates.
Alternatives
Use Tree for validated hierarchical moves or Organizer for coordinate-based dragging.
Setup and limitations
Bind items and supply stable unique IDs across every zone that can exchange items. Independent lists also need distinct list IDs: ungrouped lists with the same ID share a DnD type. SortableGroup is the only context provider needed, not Root. Hover/consider changes update an internal preview; finalize assigns items before calling onchange, which fires only if membership/order changed. During transfer, fromIndex or toIndex may be -1 for a list the item enters/leaves. Callbacks are notifications, not cancellable validation hooks; legacy rules/typeField and preview props do not enforce restrictions in this implementation. There is no dedicated disabled prop. Persist on onchange and handle application-level rejection explicitly.