SortableList
Shared ComponentsControlled vertical ordering with pointer, touch, keyboard, and move-button controls.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { SortableList, type SortableItem } from "@/components/ui/sortable-list";
import { useState } from "react";
const initial: SortableItem[] = [
{
id: "design",
label: "Design specification",
description: "Agree on the shape of the release.",
},
{
id: "keyboard",
label: "Keyboard review",
description: "Make every action reachable.",
},
{
id: "ship",
label: "Ship the release",
description: "Share something worth using.",
},
];
export default function SortableListExample() {
const [items, setItems] = useState(initial);
return (
<div className="w-full max-w-xl space-y-7">
<SortableList
label="Launch checklist"
items={items}
onItemsChange={setItems}
/>
<p role="status" className="text-xs text-muted-foreground">
Order: {items.map((item) => item.label).join(" → ")}
</p>
<SortableList
label="Locked approval order"
disabled
items={initial.slice(0, 2)}
onItemsChange={() => {}}
/>
</div>
);
}Usage
Provide ordered items with unique stable ids and apply onItemsChange to persist their new order in your own state. The installed @hello-pangea/dnd engine handles vertical drag gestures and keyboard lifting. Up/down buttons offer another way to arrange rows without dragging. The component changes order only; it does not create, delete, or save records. Use a separate workflow when order changes require confirmation or server authorization.
import { SortableList, type SortableItem } from "@/components/ui/sortable-list";Shipped as editable source in src/components/ui/sortable-list.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/sortable-list.tsx
"use client";
import { useId } from "react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import { GripVertical, ArrowUp, ArrowDown } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export interface SortableItem {
id: string;
label: string;
description?: string;
}
export function moveListItem<T>(items: T[], from: number, to: number): T[] {
if (
!Number.isInteger(from) ||
!Number.isInteger(to) ||
from < 0 ||
to < 0 ||
from >= items.length ||
to >= items.length ||
from === to
)
return items;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
}
export interface SortableListProps {
label: string;
items: SortableItem[];
onItemsChange: (items: SortableItem[]) => void;
disabled?: boolean;
className?: string;
}
export function SortableList({
label,
items,
onItemsChange,
disabled,
className,
}: SortableListProps) {
const id = useId();
return (
<section aria-label={label} className={cn("min-w-0 space-y-3", className)}>
<p className="text-sm font-medium">{label}</p>
<DragDropContext
onDragEnd={(result) => {
if (
!disabled &&
result.destination &&
result.source.index !== result.destination.index
)
onItemsChange(
moveListItem(
items,
result.source.index,
result.destination.index,
),
);
}}
>
<Droppable droppableId={id}>
{(provided) => (
<ol
ref={provided.innerRef}
{...provided.droppableProps}
className="space-y-2"
>
{items.map((item, index) => (
<Draggable
key={item.id}
draggableId={`${id}-${item.id}`}
index={index}
isDragDisabled={disabled}
disableInteractiveElementBlocking
>
{(drag, snapshot) => (
<li
ref={drag.innerRef}
{...drag.draggableProps}
className={cn(
"flex items-center gap-2 rounded-lg border bg-card p-3",
snapshot.isDragging &&
"shadow-lg ring-2 ring-primary/30",
)}
>
<button
type="button"
{...drag.dragHandleProps}
disabled={disabled}
aria-label={`Reorder ${item.label}`}
className="flex size-8 shrink-0 items-center justify-center rounded text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
<GripVertical className="size-4" />
</button>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{item.label}
</span>
{item.description && (
<span className="mt-1 block text-xs text-muted-foreground">
{item.description}
</span>
)}
</span>
<div className="flex shrink-0">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Move ${item.label} up`}
disabled={disabled || index === 0}
onClick={() =>
onItemsChange(moveListItem(items, index, index - 1))
}
>
<ArrowUp className="size-3.5" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Move ${item.label} down`}
disabled={disabled || index === items.length - 1}
onClick={() =>
onItemsChange(moveListItem(items, index, index + 1))
}
>
<ArrowDown className="size-3.5" />
</Button>
</div>
</li>
)}
</Draggable>
))}
{provided.placeholder}
</ol>
)}
</Droppable>
</DragDropContext>
{!items.length && (
<p className="rounded-lg border border-dashed p-5 text-sm text-muted-foreground">
No items to arrange.
</p>
)}
<p className="text-xs text-muted-foreground">
Drag a handle, or focus it and press Space to lift, arrows to move, and
Space to drop. Move buttons are also available.
</p>
</section>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible collection name. |
| items | SortableItem[]Default: required | Unique id, label, and optional description in current order. |
| onItemsChange | (items: SortableItem[]) => voidDefault: required | Commit a complete reordered array. |
| disabled | booleanDefault: false | Disable dragging and move actions. |
| className | stringDefault: — | Extend the container with semantic theme utilities. |