Reorder lists, grids and multi-column boards by pointer, touch or keyboard, with spoken announcements, move buttons for people who cannot drag, a floating lifted item and reduced motion.
"use client"
import * as React from "react"
import { Sortable, SortableHandle, SortableItem } from "@/components/ballmac/sortable"
const tracks = [
{ id: "t1", title: "Morning Static", artist: "Lena Okoro", length: "3:42", tone: "bg-chart-1/80" },
{ id: "t2", title: "Paper Boats", artist: "The Quiet Hours", length: "4:08", tone: "bg-chart-2/80" },
{ id: "t3", title: "Harbor Lights", artist: "Dev Anand", length: "2:56", tone: "bg-chart-3/80" },
{ id: "t4", title: "Slow Orbit", artist: "Mira & Co.", length: "5:15", tone: "bg-chart-4/80" },
{ id: "t5", title: "Nine Floors Up", artist: "Jun Matsuda", length: "3:21", tone: "bg-chart-5/80" },
]
export default function SortableDemo() {
const [order, setOrder] = React.useState(tracks.map((t) => t.id))
const byId = new Map(tracks.map((t) => [t.id, t]))
return (
<div className="w-full max-w-md">
<Sortable label="Playlist" value={order} onValueChange={setOrder}>
{order.map((id) => {
const t = byId.get(id)!
return (
<SortableItem key={id} id={id} label={t.title}>
<SortableHandle />
<span aria-hidden="true" className={`size-10 shrink-0 rounded-lg ${t.tone}`} />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{t.title}</span>
<span className="block truncate text-xs text-muted-foreground">{t.artist}</span>
</span>
<span className="text-xs tabular-nums text-muted-foreground">{t.length}</span>
</SortableItem>
)
})}
</Sortable>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/sortableInstall the dependencies.
$ pnpm add @dnd-kit/core@^6 @dnd-kit/sortable@^10 @dnd-kit/utilities@^3 motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/directionCopy the source into your project.
components/ballmac/sortable.tsx// Ballmac UI: Sortable. https://ui.ballmac.com/components/sortable // Based on dnd-kit (MIT, Copyright (c) 2021 Claudéric Demers), composed into list, grid and multi-list parts with translated announcements, move buttons and reduced motion. "use client" import * as React from "react" import { closestCorners, DndContext, DragOverlay, KeyboardSensor, PointerSensor, pointerWithin, rectIntersection, TouchSensor, useDroppable, useSensor, useSensors, type Announcements, type CollisionDetection, type DragEndEvent, type DragOverEvent, type DragStartEvent, type DraggableAttributes, type Modifier, type UniqueIdentifier, } from "@dnd-kit/core" import { arrayMove, horizontalListSortingStrategy, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS as DndCSS } from "@dnd-kit/utilities" import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, GripVertical } from "lucide-react" import { useReducedMotion } from "motion/react" import { useDirection } from "@/lib/ballmac/direction" import { useMessages } from "@/lib/ballmac/i18n" import { cn } from "@/lib/utils" type SortableOrientation = "vertical" | "horizontal" | "grid" type SortableInfo = { activeId: string; from: number; to: number } // ---------------------------------------------------------------- shared context type RootContext = { orientation: SortableOrientation disabled: boolean reduced: boolean rtl: boolean labelOf: (id: string) => string /** Where an item sits, as a group id and index, so a move button knows its neighbours. */ locate: (id: string) => { group: string; index: number; count: number } | null move: (id: string, delta: -1 | 1) => void /** What the overlay draws for the item being dragged. */ register: (id: string, render: () => React.ReactNode) => () => void activeId: string | null } const RootCtx = React.createContext<RootContext | null>(null) const useRoot = () => { const ctx = React.useContext(RootCtx) if (!ctx) throw new Error("Sortable parts must be used inside <Sortable> or <SortableGroups>.") return ctx } type ItemContext = { id: string label: string attributes: DraggableAttributes | null listeners: ReturnType<typeof useSortable>["listeners"] setActivatorNodeRef: ((el: HTMLElement | null) => void) | null dragging: boolean overlay: boolean } const ItemCtx = React.createContext<ItemContext | null>(null) const useItem = () => { const ctx = React.useContext(ItemCtx) if (!ctx) throw new Error("SortableHandle and SortableMove must be used inside <SortableItem>.") return ctx } // ---------------------------------------------------------------- engine pieces both lists share const strategyFor = (o: SortableOrientation) => (o === "vertical" ? verticalListSortingStrategy : o === "horizontal" ? horizontalListSortingStrategy : rectSortingStrategy) /** Keeps a list on its own axis while it is dragged. */ const axisModifier = (o: SortableOrientation): Modifier | undefined => o === "vertical" ? ({ transform }) => ({ ...transform, x: 0 }) : o === "horizontal" ? ({ transform }) => ({ ...transform, y: 0 }) : undefined function useSortableSensors() { return useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(TouchSensor, { activationConstraint: { delay: 160, tolerance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ) } /** The pieces of one drag the page needs to show: what is lifted, and a live region for the keyboard moves of the move buttons. */ function useEngineState(labelOf: (id: string) => string) { const msg = useMessages() const [activeId, setActiveId] = React.useState<string | null>(null) const [spoken, setSpoken] = React.useState("") const renderers = React.useRef(new Map<string, () => React.ReactNode>()) const register = React.useCallback((id: string, render: () => React.ReactNode) => { renderers.current.set(id, render) return () => { renderers.current.delete(id) } }, []) const instructions = msg( "sortable.instructions", "To pick up an item, press Space or Enter. While dragging, use the arrow keys to move it, Space or Enter to drop it, and Escape to cancel." ) return { msg, activeId, setActiveId, spoken, setSpoken, renderers, register, instructions, labelOf } } // ---------------------------------------------------------------- Sortable: one list or grid type SortableProps = Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> & { /** The ids of the items, in order. */ value: string[] /** Called with the new order when an item is dropped or moved with a button. Keep it in state and render the items in that order. */ onValueChange: (ids: string[], info: SortableInfo) => void /** What the list is, read by screen readers: "Playlist". */ label: string /** "vertical" (default) and "horizontal" lists move on one axis; "grid" moves anywhere. */ orientation?: SortableOrientation /** Says each item's name when it is picked up and dropped. Defaults to the item's `label`, then its id. */ getLabel?: (id: string) => string disabled?: boolean /** Draw the lifted item in a floating layer under the pointer. Turn off for items that need to stay in place. */ overlay?: boolean /** The items: `SortableItem`s, in the same order as `value`. */ children: React.ReactNode } /** * Reorder a list by dragging. Use a handle, the keyboard (Space to lift, arrows to move, Space to drop) or the move buttons, which are the * alternative for people who cannot drag. The new order is yours to keep: `value` and `onValueChange`. */ function Sortable({ value: ids, onValueChange, label, orientation = "vertical", getLabel, disabled = false, overlay = true, className, children, ...props }: SortableProps) { const dir = useDirection() const reduced = !!useReducedMotion() const labels = React.useRef(new Map<string, string>()) const labelOf = React.useCallback((id: string) => getLabel?.(id) ?? labels.current.get(id) ?? id, [getLabel]) const engine = useEngineState(labelOf) const { msg, activeId, setActiveId } = engine const sensors = useSortableSensors() const commit = (next: string[], info: SortableInfo) => onValueChange(next, info) const position = (id: UniqueIdentifier | undefined) => ids.indexOf(String(id)) + 1 const announcements: Announcements = { onDragStart: ({ active }) => msg("sortable.pickedUp", "Picked up {item}. It is position {position} of {total}.", { item: labelOf(String(active.id)), position: position(active.id), total: ids.length }), onDragOver: ({ active, over }) => (over ? msg("sortable.over", "{item} is over position {position} of {total}.", { item: labelOf(String(active.id)), position: position(over.id), total: ids.length }) : undefined), onDragEnd: ({ active, over }) => over ? msg("sortable.dropped", "Dropped {item} at position {position} of {total}.", { item: labelOf(String(active.id)), position: position(over.id), total: ids.length }) : msg("sortable.droppedBack", "Dropped {item}. It did not move.", { item: labelOf(String(active.id)) }), onDragCancel: ({ active }) => msg("sortable.cancelled", "Cancelled. {item} went back to position {position}.", { item: labelOf(String(active.id)), position: position(active.id) }), } const onDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id)) const onDragEnd = (e: DragEndEvent) => { setActiveId(null) const { active, over } = e if (!over || active.id === over.id) return const from = ids.indexOf(String(active.id)) const to = ids.indexOf(String(over.id)) if (from < 0 || to < 0) return commit(arrayMove(ids, from, to), { activeId: String(active.id), from, to }) } const move = React.useCallback( (id: string, delta: -1 | 1) => { const from = ids.indexOf(id) const to = from + delta if (from < 0 || to < 0 || to >= ids.length) return commit(arrayMove(ids, from, to), { activeId: id, from, to }) engine.setSpoken(msg("sortable.moved", "Moved {item} to position {position} of {total}.", { item: labelOf(id), position: to + 1, total: ids.length })) }, // eslint-disable-next-line react-hooks/exhaustive-deps [ids, labelOf] ) useRefocusAfterMove(ids) const ctx: RootContext = { orientation, disabled, reduced, rtl: dir === "rtl", labelOf, locate: (id) => { const index = ids.indexOf(id) return index < 0 ? null : { group: "", index, count: ids.length } }, move, register: engine.register, activeId, } return ( <RootCtx.Provider value={ctx}> <LabelRegistry.Provider value={labels}> <DndContext sensors={sensors} collisionDetection={closestCorners} modifiers={axisModifier(orientation) ? [axisModifier(orientation)!] : undefined} onDragStart={onDragStart} onDragEnd={onDragEnd} onDragCancel={() => setActiveId(null)} accessibility={{ announcements, screenReaderInstructions: { draggable: engine.instructions } }} > <SortableContext items={ids} strategy={strategyFor(orientation)} disabled={disabled}> <div role="list" aria-label={label} data-slot="sortable" data-orientation={orientation} className={cn( orientation === "vertical" && "flex flex-col gap-2", orientation === "horizontal" && "flex flex-row flex-wrap gap-2", orientation === "grid" && "grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3", className )} {...props} > {children} </div> </SortableContext> {overlay ? <Overlay engine={engine} reduced={reduced} /> : null} </DndContext> <span data-slot="sortable-status" role="status" aria-live="polite" className="sr-only"> {engine.spoken} </span> </LabelRegistry.Provider> </RootCtx.Provider> ) } const LabelRegistry = React.createContext<React.MutableRefObject<Map<string, string>> | null>(null) function Overlay({ engine, reduced }: { engine: ReturnType<typeof useEngineState>; reduced: boolean }) { const { activeId, renderers } = engine return ( <DragOverlay dropAnimation={reduced ? null : { duration: 180, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" }} zIndex={60}> {activeId ? renderers.current.get(activeId)?.() ?? null : null} </DragOverlay> ) } /** After a move button changes the order, the browser may drop focus as the row moves; put it back on the same button. */ function useRefocusAfterMove(order: unknown) { const pending = React.useRef<string | null>(null) React.useEffect(() => { const target = pending.current if (!target) return pending.current = null Array.from(document.querySelectorAll<HTMLElement>("[data-sortable-move]")) .find((el) => el.dataset.sortableMove === target) ?.focus() }, [order]) React.useEffect(() => { const onClick = (e: MouseEvent | KeyboardEvent) => { const el = (e.target as HTMLElement | null)?.closest<HTMLElement>("[data-sortable-move]") pending.current = el?.dataset.sortableMove ?? null } document.addEventListener("click", onClick, true) return () => document.removeEventListener("click", onClick, true) }, []) } // ---------------------------------------------------------------- SortableItem type SortableItemProps = Omit<React.ComponentProps<"div">, "id"> & { /** The item's id, one of the ids in `value`. */ id: string /** The item's name for screen readers: "Chorus". */ label?: string /** Drag the whole item. Leave it off and add a `SortableHandle` for items that hold buttons, links or inputs. */ dragEverywhere?: boolean disabled?: boolean } const itemBase = "relative flex min-w-0 items-center gap-3 rounded-xl border bg-card px-3 py-2.5 text-sm text-card-foreground shadow-xs outline-none transition-[box-shadow,border-color,opacity] duration-150 motion-reduce:transition-none" function SortableItem({ id, label, dragEverywhere = false, disabled: itemDisabled, className, children, ...props }: SortableItemProps) { const root = useRoot() const labels = React.useContext(LabelRegistry) const name = label ?? root.labelOf(id) React.useEffect(() => { if (label && labels) labels.current.set(id, label) return () => { if (labels) labels.current.delete(id) } }, [id, label, labels]) const sortable = useSortable({ id, disabled: root.disabled || itemDisabled, transition: root.reduced ? null : { duration: 200, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" }, animateLayoutChanges: root.reduced ? () => false : undefined, }) const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging } = sortable // what the floating copy shows: the same content, drawn without the sortable hook const content = ( <div data-slot="sortable-item" data-overlay="" role="listitem" className={cn(itemBase, "cursor-grabbing scale-[1.02] shadow-xl ring-1 ring-ring/30", className)} {...props}> <ItemCtx.Provider value={{ id, label: name, attributes: null, listeners: undefined, setActivatorNodeRef: null, dragging: true, overlay: true }}>{children}</ItemCtx.Provider> </div> ) const latest = React.useRef(content) latest.current = content const { register } = root React.useEffect(() => register(id, () => latest.current), [register, id]) const whole = dragEverywhere && !(root.disabled || itemDisabled) return ( <div ref={setNodeRef} data-slot="sortable-item" data-dragging={isDragging || undefined} data-sortable-id={id} style={{ transform: DndCSS.Translate.toString(transform), transition }} className={cn( itemBase, whole && "cursor-grab touch-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:cursor-grabbing", "data-[dragging=true]:border-dashed data-[dragging=true]:opacity-40 data-[dragging=true]:shadow-none", className )} {...(whole ? { ...attributes, ...listeners, "aria-label": name, tabIndex: 0 } : {})} {...props} role="listitem" > <ItemCtx.Provider value={{ id, label: name, attributes, listeners, setActivatorNodeRef, dragging: isDragging, overlay: false }}>{children}</ItemCtx.Provider> </div> ) } // ---------------------------------------------------------------- handle and move buttons type SortableHandleProps = Omit<React.ComponentProps<"button">, "children"> & { /** Replace the grip icon. */ children?: React.ReactNode } /** The grip that starts a drag. It is a button, so the keyboard can lift the item with Space or Enter. */ function SortableHandle({ className, children, "aria-label": ariaLabel, ...props }: SortableHandleProps) { const msg = useMessages() const item = useItem() const root = useRoot() const grip = children ?? <GripVertical className="size-4" aria-hidden="true" /> const classes = cn( "inline-flex size-8 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 active:cursor-grabbing disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none", className ) if (item.overlay) { return ( <span data-slot="sortable-handle" aria-hidden="true" className={cn(classes, "cursor-grabbing text-foreground")}> {grip} </span> ) } return ( <button type="button" data-slot="sortable-handle" ref={item.setActivatorNodeRef} disabled={root.disabled} aria-label={ariaLabel ?? msg("sortable.handle", "Reorder {item}", { item: item.label })} className={classes} {...item.attributes} {...item.listeners} {...props} > {grip} </button> ) } type SortableMoveProps = Omit<React.ComponentProps<"button">, "children"> & { /** Which way the item goes: toward the start of the list, or toward the end. */ direction: "previous" | "next" children?: React.ReactNode } /** * A plain button that moves the item one place. Dragging is hard or impossible for some people, so give them this too. */ function SortableMove({ direction, className, children, "aria-label": ariaLabel, ...props }: SortableMoveProps) { const msg = useMessages() const item = useItem() const root = useRoot() if (item.overlay) return null const place = root.locate(item.id) const atEdge = !place || (direction === "previous" ? place.index === 0 : place.index === place.count - 1) const vertical = root.orientation === "vertical" const Icon = vertical ? (direction === "previous" ? ChevronUp : ChevronDown) : direction === "previous" ? ChevronLeft : ChevronRight return ( <button type="button" data-slot="sortable-move" data-sortable-move={`${item.id}:${direction}`} disabled={root.disabled || atEdge} onClick={() => root.move(item.id, direction === "previous" ? -1 : 1)} aria-label={ariaLabel ?? (direction === "previous" ? msg("sortable.moveEarlier", "Move {item} earlier", { item: item.label }) : msg("sortable.moveLater", "Move {item} later", { item: item.label }))} className={cn( "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none", !vertical && "rtl:rotate-180", className )} {...props} > {children ?? <Icon className="size-4" aria-hidden="true" />} </button> ) } // ---------------------------------------------------------------- SortableGroups: several lists, items move between them type SortableGroupsValue = Record<string, string[]> type SortableGroupsInfo = { activeId: string; fromGroup: string; toGroup: string; from: number; to: number } type SortableGroupsProps = Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> & { /** The ids in each group: `{ todo: ["a", "b"], doing: ["c"] }`. The object's key order is the order of the groups. */ value: SortableGroupsValue /** Called with the new arrangement when an item is dropped or moved with a button. Keep it in state and render the items from it. */ onValueChange: (groups: SortableGroupsValue, info: SortableGroupsInfo) => void /** What the set of lists is: "Board". */ label: string getLabel?: (id: string) => string /** Says a group's name in announcements. Defaults to the group id. */ getGroupLabel?: (groupId: string) => string /** Orientation of the items inside each group. */ orientation?: SortableOrientation disabled?: boolean children: React.ReactNode } /** Several lists that share items: drag a card from one column to another, by pointer, touch or keyboard. */ function SortableGroups({ value: groups, onValueChange, label, getLabel, getGroupLabel, orientation = "vertical", disabled = false, className, children, ...props }: SortableGroupsProps) { const dir = useDirection() const reduced = !!useReducedMotion() // while an item is carried between lists the new arrangement is held here, and only given to the caller when it is dropped const [draft, setDraft] = React.useState<SortableGroupsValue | null>(null) const current = draft ?? groups const labels = React.useRef(new Map<string, string>()) const labelOf = React.useCallback((id: string) => getLabel?.(id) ?? labels.current.get(id) ?? id, [getLabel]) const groupLabel = (g: string) => getGroupLabel?.(g) ?? g const engine = useEngineState(labelOf) const { msg, activeId, setActiveId } = engine const sensors = useSortableSensors() const origin = React.useRef<{ group: string; index: number } | null>(null) const groupOf = (id: UniqueIdentifier, from: SortableGroupsValue = current) => (id in from ? String(id) : Object.keys(from).find((g) => from[g]!.includes(String(id)))) const commit = (next: SortableGroupsValue, info: SortableGroupsInfo) => onValueChange(next, info) const collision: CollisionDetection = (args) => { const pointer = pointerWithin(args) const hits = pointer.length ? pointer : rectIntersection(args) return hits.length ? hits : closestCorners(args) } const spot = (id: UniqueIdentifier) => { const g = groupOf(id) return g ? { group: g, position: String(id) in current ? current[String(id)]!.length : current[g]!.indexOf(String(id)) + 1, total: current[g]!.length } : null } const announcements: Announcements = { onDragStart: ({ active }) => { const s = spot(active.id) return s ? msg("sortable.groupPickedUp", "Picked up {item} from {group}. It is position {position} of {total}.", { item: labelOf(String(active.id)), group: groupLabel(s.group), position: s.position, total: s.total }) : undefined }, onDragOver: ({ active, over }) => { const s = over ? spot(over.id) : null return s ? msg("sortable.groupOver", "{item} is over {group}, position {position} of {total}.", { item: labelOf(String(active.id)), group: groupLabel(s.group), position: Math.max(1, s.position), total: s.total }) : undefined }, onDragEnd: ({ active }) => { const s = spot(active.id) return s ? msg("sortable.groupDropped", "Dropped {item} in {group} at position {position} of {total}.", { item: labelOf(String(active.id)), group: groupLabel(s.group), position: s.position, total: s.total }) : undefined }, onDragCancel: ({ active }) => msg("sortable.groupCancelled", "Cancelled. {item} went back to {group}.", { item: labelOf(String(active.id)), group: groupLabel(origin.current?.group ?? "") }), } const onDragStart = (e: DragStartEvent) => { const id = String(e.active.id) setActiveId(id) const g = groupOf(id) origin.current = g ? { group: g, index: current[g]!.indexOf(id) } : null } const onDragOver = (e: DragOverEvent) => { const { active, over } = e if (!over) return const from = groupOf(active.id) const to = groupOf(over.id) if (!from || !to || from === to) return const base = draft ?? groups const fromList = base[from]! const toList = base[to]! const activeIndex = fromList.indexOf(String(active.id)) const overIndex = String(over.id) in base ? toList.length : toList.indexOf(String(over.id)) const rect = active.rect.current.translated const below = rect && over.rect ? (orientation === "horizontal" ? rect.left > over.rect.left + over.rect.width / 2 : rect.top > over.rect.top + over.rect.height / 2) : false const at = overIndex < 0 ? toList.length : overIndex + (String(over.id) in base ? 0 : below ? 1 : 0) setDraft({ ...base, [from]: fromList.filter((x) => x !== String(active.id)), [to]: [...toList.slice(0, at), fromList[activeIndex]!, ...toList.slice(at)], }) } const onDragEnd = (e: DragEndEvent) => { setActiveId(null) const { active, over } = e const start = origin.current origin.current = null let next = draft ?? groups setDraft(null) if (over && start) { const g = groupOf(active.id, next) const overGroup = groupOf(over.id, next) if (g && overGroup === g && active.id !== over.id) { const list = next[g]! const from = list.indexOf(String(active.id)) const to = String(over.id) in next ? list.length - 1 : list.indexOf(String(over.id)) if (from >= 0 && to >= 0) next = { ...next, [g]: arrayMove(list, from, to) } } } const end = groupOf(active.id, next) if (!start || !end) return const index = next[end]!.indexOf(String(active.id)) if (end === start.group && index === start.index) return commit(next, { activeId: String(active.id), fromGroup: start.group, toGroup: end, from: start.index, to: index }) } const move = React.useCallback( (id: string, delta: -1 | 1) => { const g = groupOf(id, groups) if (!g) return const list = groups[g]! const from = list.indexOf(id) const to = from + delta if (to < 0 || to >= list.length) return commit({ ...groups, [g]: arrayMove(list, from, to) }, { activeId: id, fromGroup: g, toGroup: g, from, to }) engine.setSpoken(msg("sortable.groupMoved", "Moved {item} to position {position} of {total} in {group}.", { item: labelOf(id), position: to + 1, total: list.length, group: groupLabel(g) })) }, // eslint-disable-next-line react-hooks/exhaustive-deps [groups, labelOf] ) useRefocusAfterMove(groups) const ctx: RootContext = { orientation, disabled, reduced, rtl: dir === "rtl", labelOf, locate: (id) => { const g = groupOf(id) return g ? { group: g, index: current[g]!.indexOf(id), count: current[g]!.length } : null }, move, register: engine.register, activeId, } return ( <RootCtx.Provider value={ctx}> <LabelRegistry.Provider value={labels}> <GroupsCtx.Provider value={{ groups: current, groupLabel }}> <DndContext sensors={sensors} collisionDetection={collision} onDragStart={onDragStart} onDragOver={onDragOver} onDragEnd={onDragEnd} onDragCancel={() => { setActiveId(null) setDraft(null) }} accessibility={{ announcements, screenReaderInstructions: { draggable: engine.instructions } }} > <div role="group" aria-label={label} data-slot="sortable-groups" className={cn("flex items-start gap-4 overflow-x-auto", className)} {...props}> {children} </div> <Overlay engine={engine} reduced={reduced} /> </DndContext> <span data-slot="sortable-status" role="status" aria-live="polite" className="sr-only"> {engine.spoken} </span> </GroupsCtx.Provider> </LabelRegistry.Provider> </RootCtx.Provider> ) } const GroupsCtx = React.createContext<{ groups: SortableGroupsValue; groupLabel: (g: string) => string } | null>(null) type SortableGroupProps = Omit<React.ComponentProps<"div">, "id"> & { /** The group's key in `value`. */ id: string /** The group's name for screen readers. Defaults to `getGroupLabel(id)`. */ label?: string } /** One list inside `SortableGroups`. Put `SortableItem`s in it. An empty group still accepts drops. */ function SortableGroup({ id, label, className, children, ...props }: SortableGroupProps) { const root = useRoot() const g = React.useContext(GroupsCtx) if (!g) throw new Error("SortableGroup must be used inside <SortableGroups>.") const ids = g.groups[id] ?? [] const { setNodeRef, isOver } = useDroppable({ id }) return ( <SortableContext items={ids} strategy={strategyFor(root.orientation)} disabled={root.disabled}> <div ref={setNodeRef} role="list" aria-label={label ?? g.groupLabel(id)} data-slot="sortable-group" data-over={isOver || undefined} className={cn( "flex min-h-24 min-w-0 flex-1 gap-2 rounded-xl border border-dashed bg-muted/40 p-2 transition-colors data-[over=true]:border-ring data-[over=true]:bg-accent/60 motion-reduce:transition-none", root.orientation === "vertical" ? "flex-col" : "flex-row flex-wrap", className )} {...props} > {children} </div> </SortableContext> ) } export { Sortable, SortableGroup, SortableGroups, SortableHandle, SortableItem, SortableMove, type SortableGroupProps, type SortableGroupsInfo, type SortableGroupsProps, type SortableGroupsValue, type SortableHandleProps, type SortableInfo, type SortableItemProps, type SortableMoveProps, type SortableOrientation, type SortableProps, }Update the import paths to match your project setup.
Usage
import { Sortable, SortableGroup, SortableGroups, SortableHandle, SortableItem, SortableMove } from "@/components/ballmac/sortable"The full example is in the Code tab above.
Examples
Dashboard grid
"use client"
import * as React from "react"
import { Sortable, SortableItem } from "@/components/ballmac/sortable"
const widgets = [
{ id: "revenue", title: "Revenue", value: "$48.2k", note: "+12% this week" },
{ id: "signups", title: "Sign-ups", value: "1,284", note: "+4% this week" },
{ id: "churn", title: "Churn", value: "1.9%", note: "−0.3 pts" },
{ id: "latency", title: "p95 latency", value: "182 ms", note: "Within target" },
{ id: "tickets", title: "Open tickets", value: "37", note: "9 waiting on us" },
{ id: "nps", title: "NPS", value: "58", note: "Last 30 days" },
]
export default function SortableGrid() {
const [order, setOrder] = React.useState(widgets.map((w) => w.id))
const byId = new Map(widgets.map((w) => [w.id, w]))
return (
<Sortable label="Dashboard widgets" orientation="grid" value={order} onValueChange={setOrder} className="w-full max-w-xl">
{order.map((id) => {
const w = byId.get(id)!
return (
<SortableItem key={id} id={id} label={w.title} dragEverywhere className="flex-col items-start gap-1 p-4">
<span className="text-xs font-medium text-muted-foreground">{w.title}</span>
<span className="text-2xl font-semibold tabular-nums tracking-tight">{w.value}</span>
<span className="text-xs text-muted-foreground">{w.note}</span>
</SortableItem>
)
})}
</Sortable>
)
}Horizontal chips
Order the filters. The first one is the default.
"use client"
import * as React from "react"
import { Sortable, SortableItem } from "@/components/ballmac/sortable"
const filters = ["Design", "Engineering", "Marketing", "Support", "Finance", "People"]
export default function SortableHorizontal() {
const [order, setOrder] = React.useState(filters)
return (
<div className="w-full max-w-lg space-y-3">
<p className="text-sm text-muted-foreground">Order the filters. The first one is the default.</p>
<Sortable label="Filter order" orientation="horizontal" value={order} onValueChange={setOrder}>
{order.map((name) => (
<SortableItem key={name} id={name} dragEverywhere className="rounded-full px-4 py-1.5">
{name}
</SortableItem>
))}
</Sortable>
</div>
)
}Board with columns
To do3
In progress2
Done2
"use client"
import * as React from "react"
import { SortableGroup, SortableGroups, SortableHandle, SortableItem, type SortableGroupsValue } from "@/components/ballmac/sortable"
const titles: Record<string, string> = {
"fix-login": "Fix the sign-in redirect",
"invoice-pdf": "Invoice PDF layout",
"docs-rtl": "Document mirrored layouts",
"empty-states": "Draw empty states",
"rate-limit": "Rate limit the public API",
"release-notes": "Write release notes",
"csv-export": "CSV export for reports",
}
const columns = { todo: "To do", doing: "In progress", done: "Done" } as const
export default function SortableBoard() {
const [groups, setGroups] = React.useState<SortableGroupsValue>({
todo: ["fix-login", "invoice-pdf", "docs-rtl"],
doing: ["empty-states", "rate-limit"],
done: ["release-notes", "csv-export"],
})
return (
<SortableGroups label="Sprint board" value={groups} onValueChange={setGroups} getLabel={(id) => titles[id] ?? id} getGroupLabel={(g) => columns[g as keyof typeof columns]} className="w-full max-w-3xl">
{Object.keys(groups).map((g) => (
<div key={g} className="min-w-0 flex-1 space-y-2">
<h3 className="flex items-center justify-between px-1 text-sm font-medium">
{columns[g as keyof typeof columns]}
<span className="rounded-full bg-muted px-2 py-0.5 text-xs tabular-nums text-muted-foreground">{groups[g]!.length}</span>
</h3>
<SortableGroup id={g}>
{groups[g]!.map((id) => (
<SortableItem key={id} id={id} className="items-start gap-2 p-2.5">
<SortableHandle className="-ms-1 size-7" />
<span className="min-w-0 flex-1 pt-1 leading-snug">{titles[id]}</span>
</SortableItem>
))}
</SortableGroup>
</div>
))}
</SortableGroups>
)
}With move buttons
Drag the grip, use Space and the arrow keys on it, or press the arrows.
"use client"
import * as React from "react"
import { Sortable, SortableHandle, SortableItem, SortableMove } from "@/components/ballmac/sortable"
const steps = ["Collect payment details", "Confirm the shipping address", "Choose a delivery slot", "Review the order", "Place the order"]
export default function SortableButtons() {
const [order, setOrder] = React.useState(steps)
return (
<div className="w-full max-w-md space-y-3">
<Sortable label="Checkout steps" value={order} onValueChange={setOrder}>
{order.map((step, i) => (
<SortableItem key={step} id={step}>
<SortableHandle />
<span aria-hidden="true" className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium tabular-nums">
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate">{step}</span>
<span className="flex">
<SortableMove direction="previous" />
<SortableMove direction="next" />
</span>
</SortableItem>
))}
</Sortable>
<p className="text-xs text-muted-foreground">Drag the grip, use Space and the arrow keys on it, or press the arrows.</p>
</div>
)
}API reference
<Sortable>
| Prop | Type | Default |
|---|---|---|
value*The ids of the items, in order. | string[] | — |
onValueChange*Called with the new order when an item is dropped or moved with a button. Keep it in state and render the items in that order. | (ids: string[], info: SortableInfo) => void | — |
label*What the list is, read by screen readers: "Playlist". | string | — |
orientation"vertical" (default) and "horizontal" lists move on one axis; "grid" moves anywhere. | SortableOrientation | "vertical" |
getLabelSays each item's name when it is picked up and dropped. Defaults to the item's `label`, then its id. | (id: string) => string | — |
disabled | boolean | false |
overlayDraw the lifted item in a floating layer under the pointer. Turn off for items that need to stay in place. | boolean | true |
children*The items: `SortableItem`s, in the same order as `value`. | React.ReactNode | — |
<SortableItem>
| Prop | Type | Default |
|---|---|---|
id*The item's id, one of the ids in `value`. | string | — |
labelThe item's name for screen readers: "Chorus". | string | — |
dragEverywhereDrag the whole item. Leave it off and add a `SortableHandle` for items that hold buttons, links or inputs. | boolean | false |
disabled | boolean | — |
<SortableHandle>
| Prop | Type | Default |
|---|---|---|
childrenReplace the grip icon. | React.ReactNode | — |
<SortableMove>
| Prop | Type | Default |
|---|---|---|
direction*Which way the item goes: toward the start of the list, or toward the end. | "previous" | "next" | — |
children | React.ReactNode | — |
<SortableGroups>
| Prop | Type | Default |
|---|---|---|
value*The ids in each group: `{ todo: ["a", "b"], doing: ["c"] }`. The object's key order is the order of the groups. | SortableGroupsValue | — |
onValueChange*Called with the new arrangement when an item is dropped or moved with a button. Keep it in state and render the items from it. | (groups: SortableGroupsValue, info: SortableGroupsInfo) => void | — |
label*What the set of lists is: "Board". | string | — |
getLabel | (id: string) => string | — |
getGroupLabelSays a group's name in announcements. Defaults to the group id. | (groupId: string) => string | — |
orientationOrientation of the items inside each group. | SortableOrientation | "vertical" |
disabled | boolean | false |
children* | React.ReactNode | — |
<SortableGroup>
| Prop | Type | Default |
|---|---|---|
id*The group's key in `value`. | string | — |
labelThe group's name for screen readers. Defaults to `getGroupLabel(id)`. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Space / Enter on the handle | Picks up the item; the same keys drop it |
| Arrow keys | Move the lifted item; the new position is announced |
| Escape | Cancels and puts the item back |
| SortableMove buttons | Move an item one place with a click, for people who cannot drag (WCAG 2.5.7) |
| Screen readers | Instructions, picked-up, over, dropped and cancelled messages are read out, in the page's language |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 5 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
37 tab stops, each showing a visible change when it takes focus. 5 documented keys.
Not applicable: Loading, empty and error
Nothing loads or fails: it reorders the items it is given; an empty list or group simply draws no items.
Passed: Automated tests
Covered by sortable.test.tsx.
For information: Footprint
7.4 KB of source (gzip) in 1 file. Installs @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, motion and 1 more.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
Hold the order in state: <Sortable label value={ids} onValueChange={setIds}> with a <SortableItem id> per id, each holding a <SortableHandle>. SortableGroups and SortableGroup move items between lists. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Sortable (@ballmac/sortable) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Playlists, checklists, filters and settings people put in their own order
- Dashboard tiles in a grid and chips in a row
- Boards where cards move between columns
Not for
- A full kanban product with cards, tags and assignees; use kanban-board
- Sorting by a column's value; use data-table or data-grid
- Reordering rows of a huge virtualized list
Registry JSON: https://ui.ballmac.com/r/sortable.json
Credits
Based on dnd-kit, adapted by Ballmac. Free to use in personal and commercial projects.
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Badge
A small pill label in four variants with an optional status dot and success, warning and error tones. Style links with badgeVariants().
Kanban Board
A responsive task board with pointer drag and keyboard movement between columns.
Accordion
Vertically stacked disclosure sections on Radix Accordion, single or multiple open, with hairline dividers, a rotating plus and height animation.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.