Reorder list
Drag to reorder with a mouse, a finger or the keyboard - and persist once per drop.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-reorder-list.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-reorder-list.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-reorder-list.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-reorder-list.jsonHTML5 draggable gets a sortable list working on a desktop in an afternoon, and then it does not work
on a phone (touch devices do not fire its events), and there is no way to do it from the keyboard at
all. The bound array usually updates on every row the pointer crosses, so an $effect that saves the
order sends a request for each one.
- Install
- Lint
- Test
- Build
- Deploy
Saves: 0 · one per drag, not per row
<script lang="ts">
import ReorderList from '#lib/components/coral/kit/reorder-list/reorder-list.svelte';
let stages = $state(['Install', 'Lint', 'Test', 'Build', 'Deploy']);
let saves = $state(0);
</script>
<div class="flex w-72 flex-col gap-3">
<ReorderList
bind:items={stages}
aria-label="Pipeline stages"
itemClass="rounded-md border px-2 py-2 text-sm"
handleLabel={(label) => `Move ${label}`}
instructions="Press Space to pick up. Use the arrow keys to move, Space to drop and Escape to cancel."
grabbed={(label, position, total) => `${label} picked up. Position ${position} of ${total}.`}
moved={(label, position, total) => `${label} at position ${position} of ${total}.`}
dropped={(label, position, total) => `${label} dropped at position ${position} of ${total}.`}
cancelled={(label, position, total) =>
`Cancelled. ${label} returns to position ${position} of ${total}.`}
onreorder={() => saves++}
/>
<p class="text-sm text-muted-foreground">Saves: {saves} · one per drag, not per row</p>
</div>What Coral adds
- Pointer events, not drag-and-drop: mouse, pen and touch alike, with the page kept from scrolling under the finger.
- A keyboard path: focus a handle, Space to pick up, arrows (and Home, End) to move, Space to drop, Escape to put it back. Tabbing away drops it where it is.
- Announced. Picked up, each move, dropped and cancelled are spoken with the position ("moved to position 3 of 5"), and the handle describes how to use it. All wording is replaceable.
- One write per drop: rows slide live while dragging, but
itemsandonreorderonly change on drop, and not at all for a drop back where it started. - Swaps where the eye expects. A row gives way when the dragged row's centre passes its centre, whichever part of the handle was grabbed. Neighbours slide aside, unless reduced motion is on.
Your own row
- 1 Cut the release branch Amara
- 2 Run the migration Wei
- 3 Deploy to staging Sofia
- 4 Promote to production Liam
<script lang="ts">
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import ReorderList from '#lib/components/coral/kit/reorder-list/reorder-list.svelte';
import { Badge } from '#lib/components/ui/badge/index.js';
type Task = { id: number; title: string; owner: string };
let tasks = $state<Task[]>([
{ id: 1, title: 'Cut the release branch', owner: 'Amara' },
{ id: 2, title: 'Run the migration', owner: 'Wei' },
{ id: 3, title: 'Deploy to staging', owner: 'Sofia' },
{ id: 4, title: 'Promote to production', owner: 'Liam' }
]);
</script>
<ReorderList
bind:items={tasks}
class="w-80"
aria-label="Release steps"
getKey={(task) => task.id}
getLabel={(task) => task.title}
>
{#snippet row({ item, index, handle, dragging })}
<div
class="flex w-full items-center gap-3 rounded-md border bg-background px-2 py-2 text-sm"
data-dragging={dragging || undefined}
>
<span {...handle} class="cursor-grab rounded-sm outline-offset-2 focus-visible:outline-2">
<GripVerticalIcon class="size-4" aria-hidden="true" />
</span>
<span class="w-4 text-muted-foreground">{index + 1}</span>
<span class="flex-1">{item.title}</span>
<Badge variant="secondary">{item.owner}</Badge>
</div>
{/snippet}
</ReorderList>The item snippet replaces the row's body and keeps Coral's grip. The row snippet replaces
everything: spread handle onto whatever should grab the row, whether that's a grip or the whole
row.
Entries are keyed by getKey, which defaults to the entry itself. Pass it for objects that are
recreated between renders, and never return the index, since the index is what changes.
Import
<script lang="ts">
import ReorderList from '#lib/components/coral/kit/reorder-list/reorder-list.svelte';
</script>Props
Everything a <ul> accepts stays available. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | - | Bindable. Written once per drop. |
getKey | (item: T) => unknown | identity | Identifies an entry across moves. |
getLabel | (item: T) => string | String | Names an entry for the handle and announcements. |
onreorder | (items: T[], { item, from, to }) => void | - | Once per drop that changed the order. |
disabled | boolean | false | Blocks dragging. |
handleLabel | (label: string) => string | Reorder … | Accessible name of each handle. |
instructions | string | English | Describes the keyboard controls. |
grabbed, moved, dropped, cancelled | (label, position, total) => string | English | Announcements. |
itemClass | string | - | Merged onto every row. |
item | Snippet<[ItemContext<T>]> | - | Row body, after the grip. |
row | Snippet<[ItemContext<T>]> | - | The whole row. Spread handle. |
ItemContext is { item, index, dragging, handle }. Rows being dragged carry data-dragging.
Scope
Vertical lists only, and no auto-scroll while dragging near the edge of a scrolling container. Both are real needs, and neither has come up in a list short enough for this component to be the right tool. A board with columns is a block, not this.