createSortable
Headless ordered-list primitive that owns a registry of value-bearing tickets and exposes move, swap, and reorder mutations. Pure logic — no DnD, no keyboard, no DOM — so consumers can drive it from any input modality.
Usage
createSortable extends createModel with mutation primitives over the canonical order. Drag-and-drop wiring composes with useDragDrop; keyboard reorder composes with useVirtualFocus. Consumers can drive sortable from buttons, gestures, server reconciliation, or undo/redo by calling its mutation methods.
import { createSortable } from '@vuetify/v0'
import type { SortableTicketInput } from '@vuetify/v0'
interface Task {
id: number
label: string
}
interface TaskTicket extends SortableTicketInput {
value: Task
}
const sortable = createSortable<TaskTicket>()
const [a, b, c] = sortable.onboard([
{ value: { id: 1, label: 'Cut alpha' } },
{ value: { id: 2, label: 'Ship docs' } },
{ value: { id: 3, label: 'Tweet' } },
])
sortable.move(a.id, 2)
sortable.swap(a.id, b.id)
sortable.reorder([b.id, a.id, c.id])Architecture
createSortable extends createModel, which extends createRegistry. All methods inherited from those layers are available unchanged, except on and off — those are extended with typed overloads for the move:ticket event. See createModel and createRegistry for the full inherited surface.
The composable adds four things on top of createModel:
| Addition | Layer | Purpose |
|---|---|---|
move override | sortable | Wraps registry.move to emit move:ticket with { ticket, from, to } |
swap(a, b) | sortable | Two batched move calls; emits two move:ticket events |
reorder(ids) | sortable | Strict permutation set; throws on length mismatch, unknown id, or duplicate id |
Typed on / off | sortable | Overloads narrow move:ticket callback payload to SortableMovePayload<E> |
The composable always enables events: true on the underlying registry, so move:ticket works out of the box and useProxyRegistry snapshots track moves without extra configuration. Consumer-supplied events: false is overridden — sortable’s move:ticket contract requires events to be on.
Reactivity
createSortable’s surface is mostly imperative — move, swap, and reorder mutate the registry and the reactive updates flow downstream through registry events. The composable bakes in events: true so move:ticket and the standard registry events fire without extra setup.
| Property/Method | Reactive | Notes |
|---|---|---|
size | Getter — tracks registry count via useProxyRegistry or reactive: true | |
disabled (option) | MaybeRefOrGetter<boolean> — flipping it re-enables move / swap / reorder | |
move:ticket event | Subscribe via on(); payload is SortableMovePayload<E> with { ticket, from, to } | |
move(id, toIndex) | - | Imperative; returns the moved ticket or undefined when gated |
swap(a, b) | - | Imperative; emits move:ticket twice in a batch |
reorder(ids) | - | Imperative; logs a warning and no-ops on size/unknown/duplicate violations |
Reactive iteration useProxyRegistry(sortable) returns a reactive { keys, values, entries, size } snapshot driven by registry events. Templates that iterate it stay in sync with move, swap, and reorder automatically. See Reactive snapshot for templates.
Examples
Recipes
Disabling reorder
disabled works at two scopes. Root (createSortable({ disabled })) no-ops move, swap, and reorder for the whole list. Per-ticket (register({ value, disabled: true })) no-ops move and swap for that ticket. reorder bypasses per-ticket disabled — it’s a bulk operation declaring the canonical order; if you want disabled tickets pinned, exclude their ids from the array. Registration is never gated.
const sortable = createSortable<Todo>({
disabled: toRef(() => isReadOnlyMode.value),
})
sortable.move(id, 0) // no-op when disabled.value === trueServer-reconciled order
reorder accepts a strict permutation of currently-registered ids. Use it to apply an authoritative order from the backend without diffing positions yourself.
const sortable = createSortable<Todo>()
const order = await fetchOrder() // ID[] from backend
sortable.reorder(order)Reactive snapshot for templates
useProxyRegistry returns a reactive { keys, values, entries, size } snapshot driven by registry events. Because createSortable bakes in events: true, you do not pass it explicitly.
const sortable = createSortable<Todo>()
const proxy = useProxyRegistry(sortable)
// proxy.keys, proxy.values, proxy.entries, proxy.size all track movesPair with useDragDrop
Wire useDragDrop’s onDrop callback to sortable.move to translate pointer or keyboard drags into reorder mutations. The headless contract keeps the two primitives independent — sortable owns order, drag-drop owns input.
const sortable = createSortable<Todo>()
const dnd = useDragDrop()
dnd.zones.register({
el: containerEl,
accept: ['todo'],
onDrop: (drag, position) => {
if (drag.type === 'todo') sortable.move(drag.id, position.index ?? 0)
},
})A first-class useSortableDnD adapter is on the roadmap; until then, wire useDragDrop’s callbacks to sortable.move directly.
Functions
createSortable
(_options?: SortableOptions) => SortableContext<Z, E>Create an ordered-list state primitive.
Options
disabled
MaybeRefOrGetter<boolean> | undefinedDisabled state for the entire model instance
Default: false
multiple
MaybeRefOrGetter<boolean> | undefinedAllow multiple tickets to be selected simultaneously
Default: false
Properties
selectedValues
ComputedRef<Set<E["value"] extends Ref<infer U, infer U> ? U : E["value"]>>Computed Set of selected ticket values
off
SortableEventListener<E>Methods
seek
(direction?: "first" | "last", from?: number, predicate?: (ticket) => boolean) => E | undefinedSeek for a ticket based on direction and optional predicate
emit
<K extends Extensible<RegistryEventName>>(event: K, data: EventPayload<E, K>) => voidEmit an event with data
batch
<R>(fn: () => R) => RExecute operations in a batch, deferring cache invalidation and event emission until complete
move
(id: ID, toIndex: number) => E | undefinedMove a ticket to a target index. Other tickets shift to fill. Emits `move:ticket` once when the index actually changes — a move whose `toIndex` already equals the ticket's current index is a no-op. No-ops when the root sortable is disabled or when the ticket is disabled.
swap
(a: ID, b: ID) => voidSwap two tickets' positions. Emits `move:ticket` twice — once per ticket. No-ops when the root sortable is disabled or when either ticket is disabled.