Skip to content

SortableList

Shared Components

Controlled vertical ordering with pointer, touch, keyboard, and move-button controls.

Editable launch orderDrag handlesUp/down buttonsLocked orderEmpty list
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

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
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.

SortableList props
PropType / DefaultDescription
labelstring

Default: required

Visible collection name.
itemsSortableItem[]

Default: required

Unique id, label, and optional description in current order.
onItemsChange(items: SortableItem[]) => void

Default: required

Commit a complete reordered array.
disabledboolean

Default: false

Disable dragging and move actions.
classNamestring

Default: —

Extend the container with semantic theme utilities.

Build with these too