89 / Controls
Sortable List
A list reordered by dragging a handle, or from the keyboard without one, where every move is announced.
Read the brief
Twice, slowly
Sketch three routes
On paper
Build the likely one
Thin slice first
Show someone
Before it is finished
Drag a handle, or focus one and press space to lift it.
"use client"
import * as React from "react"
import { SortableList } from "mischief-ui/sortable-list"
type Task = { id: string; name: string; note: string }
const initial: Task[] = [
{ id: "read", name: "Read the brief", note: "Twice, slowly" },
{ id: "sketch", name: "Sketch three routes", note: "On paper" },
{ id: "build", name: "Build the likely one", note: "Thin slice first" },
{ id: "show", name: "Show someone", note: "Before it is finished" },
]
export function SortableListDemo() {
const [tasks, setTasks] = React.useState(initial)
return (
<div className="w-full max-w-md">
<SortableList
items={tasks}
getKey={(task) => task.id}
getLabel={(task) => task.name}
onReorder={setTasks}
label="Tasks in order"
renderItem={(task) => (
<div>
<p className="text-sm font-semibold">{task.name}</p>
<p className="text-muted-foreground text-xs">{task.note}</p>
</div>
)}
/>
<p className="text-muted-foreground mt-3 text-xs">
Drag a handle, or focus one and press space to lift it.
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/sortable-listimport { SortableList } from "mischief-ui/sortable-list"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { GripVertical } from "lucide-react"import { cn } from "@/lib/utils" export type SortableListProps<TItem> = Omit< React.HTMLAttributes<HTMLOListElement>, "children"> & { items: readonly TItem[] getKey: (item: TItem) => string onReorder: (items: TItem[]) => void renderItem: (item: TItem, index: number) => React.ReactNodeUsage
export function Tasks() {
const [tasks, setTasks] = React.useState(initial)
return (
<SortableList
items={tasks}
getKey={(task) => task.id}
getLabel={(task) => task.name}
onReorder={setTasks}
renderItem={(task) => <p>{task.name}</p>}
/>
)
}Reordering without a pointer
Drag and drop is the version of this everyone builds and the version a keyboard cannot use. So the handle is a real button that can be focused, and the whole operation works from there.
- Space or Enter lifts the item, and says so along with what to do next.
- Up and down arrows move it while it is lifted, announcing its new position each time.
- Space or Enter drops it.
- Escape puts the list back the way it was before the lift.
A lifted item is marked as pressed and ringed, so its state is visible as well as announced.
Saying where things went
Every move reports the item and its new position out of the total. A list that rearranges itself in silence is unusable to anyone not watching it, and that includes anyone who dragged something and looked away.
getLabel={(task) => task.name}It owns no order of its own
The list is whatever you passed and every change comes back through onReorder, including the ones made while a drag is still in progress. There is no internal copy to fall out of step with yours, and persisting the order is a matter of saving what you were handed.
API
itemsTItem[]The list, in its current order.getKey(item) => stringA stable key for each item.onReorder(items) => voidThe list in its new order.renderItem(item, index) => ReactNodeWhat each row shows.getLabel(item) => stringNames the item on its handle and in announcements. Worth passing.labelstringNames the list. Defaults to "Sortable list".Accessibility
An ordered list whose handles are named buttons carrying the item's name. The full operation is available from the keyboard, with lifted state exposed as pressed, and every move and cancellation announced politely. Escape restores the order from before the lift.