Sortable Feature #
Drag-and-drop reordering with smooth item shifting.
Overview #
The withSortable() feature enables drag-and-drop reordering of items in a virtual list. Grab an item (or its handle) and drag to reorder — surrounding items shift out of the way in real time, like iOS list reordering.
Import:
import { vlist, withSortable } from 'vlist';
Key characteristics:
- Live reorder — items shift with smooth CSS transitions as you drag, giving immediate visual feedback
- Ghost clone — a semi-transparent clone follows the pointer; the original item is hidden
- Handle support — optionally restrict drag initiation to a handle element (e.g. a grip icon)
- Edge auto-scroll — the list scrolls automatically when dragging near the top or bottom of the viewport, with quadratic speed ramp
- Drag threshold — a minimum pointer movement prevents accidental drags on click
- Keyboard reordering — grab, move, drop, and cancel items entirely via keyboard
- Accessible — ARIA attributes and live region announcements for screen readers
- Data-agnostic — the feature does NOT reorder your data. On drop, it emits
sort:endwith{ fromIndex, toIndex }— you reorder the array and callsetItems()
Quick Start #
import { vlist, withSortable } from 'vlist';
const items = [
{ id: 1, name: 'Review pull request' },
{ id: 2, name: 'Update documentation' },
{ id: 3, name: 'Fix login redirect' },
];
const list = vlist({
container: '#list',
items,
item: {
height: 56,
template: (item) => `
<div class="task">
<span class="grip">⠇</span>
${item.name}
</div>
`,
},
})
.use(withSortable({ handle: '.grip' }))
.build();
// Reorder data on drop
list.on('sort:end', ({ fromIndex, toIndex }) => {
const reordered = [...items];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
items.length = 0;
items.push(...reordered);
list.setItems(items);
});
// Restore order on keyboard cancel (Escape)
list.on('sort:cancel', ({ originalItems }) => {
items.length = 0;
items.push(...originalItems);
list.setItems(items);
});
API #
withSortable(config?) #
Creates the sortable feature. Accepts an optional configuration object.
withSortable(config?: SortableConfig): VListFeature
Configuration Options #
| Option | Type | Default | Description |
|---|---|---|---|
handle |
string |
undefined |
CSS selector for the drag handle within each item. When set, only elements matching this selector initiate a drag. When omitted, the entire item is draggable. |
ghostClass |
string |
'vlist-sort-ghost' |
CSS class added to the drag ghost element. Override to customize the ghost appearance. |
shiftDuration |
number |
150 |
Transition duration in ms for item shift animations. Set to 0 for instant shifts. |
edgeScrollZone |
number |
40 |
Size in px of the auto-scroll zone at viewport edges. When the pointer enters this zone during drag, the list auto-scrolls. |
edgeScrollSpeed |
number |
20 |
Auto-scroll speed in pixels per frame at the edge boundary. Speed ramps quadratically as the pointer moves deeper into the zone. |
dragThreshold |
number |
5 |
Minimum distance in px the pointer must move before drag starts. Prevents accidental drags on click. |
Instance Methods #
When withSortable is active, the list instance exposes:
list.isSorting(): boolean
Returns true while a drag or keyboard reorder is in progress.
Events #
sort:start #
Emitted when a drag begins (after the pointer crosses the drag threshold) or when an item is grabbed via keyboard (Space).
list.on('sort:start', ({ index }) => {
console.log(`Dragging item at index ${index}`);
});
| Field | Type | Description |
|---|---|---|
index |
number |
Index of the item being dragged |
sort:move #
Emitted during a pointer drag whenever the drop position changes. Use this to show live feedback (e.g. updating a status panel in real time). Not emitted for keyboard reordering (use sort:end instead, which fires per arrow key press).
list.on('sort:move', ({ fromIndex, currentIndex }) => {
console.log(`Would drop at #${currentIndex + 1} (started at #${fromIndex + 1})`);
});
| Field | Type | Description |
|---|---|---|
fromIndex |
number |
Index of the item being dragged |
currentIndex |
number |
Current drop target index |
sort:end #
Emitted when a drag completes and the item was moved to a different position. Not emitted if the item is dropped back to its original position.
For keyboard reordering, sort:end is emitted on each arrow key press (incremental moves), not just on drop.
list.on('sort:end', ({ fromIndex, toIndex }) => {
// Reorder your data array
const reordered = [...items];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
list.setItems(reordered);
});
| Field | Type | Description |
|---|---|---|
fromIndex |
number |
Original index of the dragged item |
toIndex |
number |
New index where the item was dropped |
sort:cancel #
Emitted when a keyboard reorder is cancelled via Escape. Contains the original items array from before the grab, so the consumer can restore the original order. Not emitted for pointer drags (pointer cancel simply restores visual state without data changes).
list.on('sort:cancel', ({ originalItems }) => {
list.setItems(originalItems);
});
| Field | Type | Description |
|---|---|---|
originalItems |
unknown[] |
Snapshot of the items array at the moment the item was grabbed |
Keyboard Reordering #
The sortable feature supports full keyboard-driven reordering. When composed with withSelection, the focused item (navigated via arrow keys) can be grabbed and moved.
Keys #
| Key | Action |
|---|---|
Space |
Grab the focused item (enters grab mode) |
Arrow Up / Arrow Down |
Move the grabbed item up/down by one position |
Arrow Left / Arrow Right |
Same as Up/Down (for horizontal lists) |
Space / Enter |
Drop the grabbed item at its current position |
Escape |
Cancel — restore the original order |
How It Works #
Arrow keys navigate to an item (via withSelection)
|
Space — grab the focused item
sort:start emitted
.vlist--sorting class added to root
.vlist-item--kb-sorting class added to grabbed item
Screen reader: "Grabbed {item}. Position N of M."
|
Arrow Down — move item down one position
sort:end emitted per move (consumer reorders data)
Screen reader: "{item} moved. New position N of M."
|
Arrow Down — continue moving...
|
Space — drop the item
.vlist--sorting removed
.vlist-item--kb-sorting removed
Screen reader: "{item} dropped. Final position N of M."
— or —
Escape — cancel and restore original order
sort:cancel emitted with original items snapshot
Screen reader: "Reorder cancelled. Returned to position N of M."
Interaction with Selection #
When withSortable and withSelection are both active:
- Space is intercepted by sortable for grab/drop. Use Enter to toggle selection on focused items.
- During grab mode, all keys (arrows, Space, Escape) are handled by sortable. Selection's keyboard handler is blocked.
- After drop or cancel, selection focus is updated to the item's final position.
Accessibility #
ARIA Attributes #
The feature automatically applies ARIA attributes to rendered items:
| Attribute | Value | Purpose |
|---|---|---|
aria-roledescription |
"sortable item" |
Tells screen readers this is a reorderable item |
aria-describedby |
Points to hidden instructions | Provides reorder instructions on focus |
A visually-hidden instructions element is appended to the root:
"Press Space to reorder. Use arrow keys to move, Space to drop, Escape to cancel."
Live Region Announcements #
All sort actions are announced via the shared aria-live="polite" region:
| Action | Announcement |
|---|---|
| Grab | "Grabbed {item}. Current position N of M. Use Up and Down arrow keys to move, Space to drop, Escape to cancel." |
| Move | "{item} moved. New position N of M." |
| Drop | "{item} dropped. Final position N of M." |
| Cancel | "Reorder cancelled. Returned to position N of M." |
How It Works #
Drag Lifecycle (Pointer) #
pointerdown on item (or handle)
|
pointermove crosses dragThreshold (5px)
|
sort:start emitted
Ghost clone created, original hidden (opacity: 0)
Root gets .vlist--sorting class
|
pointermove updates ghost position
Nearby items shift via CSS transform transitions
sort:move emitted each time drop position changes
Edge zones trigger auto-scroll
|
pointerup
|
Ghost animates to drop target position
Ghost removed, shifts cleared, DOM restored
|
sort:end emitted (if position changed)
Ghost Element #
The dragged item is cloned — the clone (ghost) follows the pointer with position: fixed, while the original is hidden. This approach is safer than moving the original because vlist's renderer may recycle or reposition elements during scroll.
The ghost receives structural inline styles (position, size, coordinates) and the ghostClass for visual styles. The default .vlist-sort-ghost class provides:
.vlist-sort-ghost {
opacity: 0.85;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
cursor: grabbing;
}
Override ghostClass or the CSS class to customize the ghost appearance.
Item Shifting #
Items shift out of the way using CSS transitions on their transform property. The shift direction is determined by comparing the ghost's leading edge against each item's midpoint:
- Dragging down — ghost bottom edge vs. target midpoint
- Dragging up — ghost top edge vs. target midpoint
Direction is calculated from the pointer's current position relative to the drag start position, making it stable (no flickering from frame-to-frame jitter).
Edge Auto-Scroll #
When the pointer enters the edge zone (default 40px from viewport top/bottom), the list auto-scrolls:
- Quadratic speed ramp — speed increases as
t^2wheretis depth into the zone (0 at boundary, 1 at edge) - Beyond viewport — scrolling continues when the pointer moves outside the viewport, capped at 3x max speed
- Shifts frozen — item shifts are suspended during edge scrolling to avoid jarring double-movement
- Re-entry — shifts resume when the pointer re-enters the viewport
- Boundary detection — auto-scroll stops at list boundaries (top/bottom)
Drop Animation #
On pointer release, the ghost smoothly transitions to the target item's position (both axes) via CSS transition. A transitionend listener cleans up the ghost, with a setTimeout fallback in case the event doesn't fire.
CSS Classes #
| Class | Applied to | When |
|---|---|---|
vlist-sort-ghost |
Ghost element | During pointer drag (default ghostClass) |
vlist--sorting |
Root element | During pointer drag or keyboard grab (user-select: none) |
vlist-item--kb-sorting |
Grabbed item | During keyboard grab (focus ring + selected background) |
Styling the Ghost #
Override the default ghost styles:
/* Custom ghost appearance */
.vlist-sort-ghost {
opacity: 0.9;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
border-radius: 8px;
cursor: grabbing;
}
Or use a custom class:
.use(withSortable({ ghostClass: 'my-drag-ghost' }))
Styling the Handle #
Style the drag handle to indicate it's grabbable:
.grip {
cursor: grab;
opacity: 0.4;
user-select: none;
}
.grip:hover {
opacity: 0.7;
}
/* During drag, the root gets .vlist--sorting */
.vlist--sorting .grip {
cursor: grabbing;
}
Styling the Keyboard Grab #
The default .vlist-item--kb-sorting provides a focus ring and selected background. Override to customize:
.vlist-item--kb-sorting {
outline: 2px solid var(--vlist-focus-ring, #3b82f6);
outline-offset: -2px;
background-color: var(--vlist-bg-selected);
z-index: 1;
}
Usage Examples #
Full-Item Drag (No Handle) #
const list = vlist({
container: '#list',
items: tasks,
item: {
height: 48,
template: (task) => `<div class="task">${task.name}</div>`,
},
})
.use(withSortable()) // entire item is draggable
.build();
Handle-Based Drag #
const list = vlist({
container: '#list',
items: tasks,
item: {
height: 56,
template: (task) => `
<div class="task">
<span class="drag-handle" aria-label="Drag to reorder">⠇</span>
<span class="task-name">${task.name}</span>
</div>
`,
},
})
.use(withSortable({ handle: '.drag-handle' }))
.build();
With Selection #
Sortable composes with withSelection(). Use Enter for selection toggle (Space is used for grab/drop):
import { vlist, withSortable, withSelection } from 'vlist';
const list = vlist({
container: '#list',
items: tasks,
item: {
height: 56,
template: (task, i, { selected }) => `
<div class="task ${selected ? 'task--selected' : ''}">
<span class="grip">⠇</span>
${task.name}
</div>
`,
},
})
.use(withSortable({ handle: '.grip' }))
.use(withSelection({ mode: 'single' }))
.build();
Handling Cancel #
When the user cancels a keyboard reorder with Escape, restore the original order:
list.on('sort:end', ({ fromIndex, toIndex }) => {
const reordered = [...items];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
list.setItems(reordered);
});
list.on('sort:cancel', ({ originalItems }) => {
list.setItems(originalItems);
});
Persisting Order #
Save the reordered data to your backend:
list.on('sort:end', async ({ fromIndex, toIndex }) => {
// Update local state
const reordered = [...items];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
list.setItems(reordered);
// Persist to backend
await fetch('/api/tasks/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fromIndex, toIndex }),
});
});
Live Drag Feedback #
Use sort:move to update a status panel in real time as the user drags:
list.on('sort:move', ({ fromIndex, currentIndex }) => {
const item = items[fromIndex];
const delta = currentIndex - fromIndex;
const arrow = delta > 0 ? '↓' : '↑';
statusEl.textContent =
`${item.name}: #${fromIndex + 1} → #${currentIndex + 1} (${arrow} ${Math.abs(delta)})`;
});
list.on('sort:end', ({ fromIndex, toIndex }) => {
const reordered = [...items];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
list.setItems(reordered);
statusEl.textContent = `Moved "${moved.name}" to #${toIndex + 1}`;
});
Custom Shift Speed #
Slow down the shift animation for a more deliberate feel:
.use(withSortable({
handle: '.grip',
shiftDuration: 250, // slower transitions
}))
Or disable animation entirely:
.use(withSortable({
shiftDuration: 0, // instant shifts
}))
Module Structure #
src/features/sortable/
├── index.ts # Module exports
└── feature.ts # withSortable() feature
Compatibility #
| Feature | Compatible | Notes |
|---|---|---|
withSelection() |
Yes | Space grabs items; use Enter for selection toggle |
withScrollbar() |
Yes | Custom scrollbar works during drag |
withAsync() |
Yes | Drag works on loaded items |
withScale() |
Yes | Works with compressed scroll |
withSnapshots() |
Yes | Snapshots capture order after reorder |
withGroups() |
Yes | Drag within grouped lists |
withGrid() |
No | Conflicts — sortable is for flat lists |
withMasonry() |
No | Conflicts — sortable is for flat lists |
withTable() |
No | Conflicts — use table's built-in column sorting instead |
See Also #
- Selection — Combine with sortable for selectable, reorderable lists
- Scrollbar — Custom scrollbar works during drag
Examples #
- Sortable — Task list with drag-and-drop reordering, handle toggle, and live status