Reorderable

Drag and drop reorderable list component with visual feedback and smooth animations.

Installation

npx shadcn@latest add https://spoke.georgedrury.co.uk/r/reorderable

Lineage

Spoke

Anatomy

import {
	ReorderableList,
	ReorderableItem,
	ReorderableTrigger,
	ReorderableSeparator,
} from "@/components/ui/reorderable"

export default () => {
	const [items, setItems] = useState(["Item 1", "Item 2", "Item 3"])

	return (
		<ReorderableList items={items} onReorder={setItems}>
			{items.map((item, index) => (
				<>
					<ReorderableSeparator index={index} />
					<ReorderableItem key={index} index={index}>
						<ReorderableTrigger />
						{item}
					</ReorderableItem>
				</>
			))}
			<ReorderableSeparator index={items.length} />
		</ReorderableList>
	)
}

Usage

Basic Example

"use client"

import { useState } from "react"
import {
	ReorderableList,
	ReorderableItem,
	ReorderableTrigger,
	ReorderableSeparator,
} from "@/components/ui/reorderable"

export default function ReorderableDemo() {
	const [tasks, setTasks] = useState([
		"Design landing page",
		"Implement authentication",
		"Write documentation",
	])

	return (
		<ReorderableList items={tasks} onReorder={setTasks} className="space-y-2">
			{tasks.map((task, index) => (
				<>
					<ReorderableSeparator key={`sep-${index}`} index={index} />
					<ReorderableItem
						key={task}
						index={index}
						className="flex items-center gap-2 p-4 bg-card border rounded-lg"
					>
						<ReorderableTrigger className="cursor-move">⋮⋮</ReorderableTrigger>
						<span>{task}</span>
					</ReorderableItem>
				</>
			))}
			<ReorderableSeparator index={tasks.length} />
		</ReorderableList>
	)
}

With Complex Items

"use client"

import { useState } from "react"
import { GripVertical } from "lucide-react"
import {
	ReorderableList,
	ReorderableItem,
	ReorderableTrigger,
	ReorderableSeparator,
} from "@/components/ui/reorderable"
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"

interface Project {
	id: string
	title: string
	description: string
}

export default function ProjectList() {
	const [projects, setProjects] = useState<Project[]>([
		{ id: "1", title: "Website Redesign", description: "Modernize the company website" },
		{ id: "2", title: "Mobile App", description: "Build iOS and Android apps" },
		{ id: "3", title: "API Integration", description: "Connect third-party services" },
	])

	return (
		<ReorderableList items={projects} onReorder={setProjects} className="space-y-2">
			{projects.map((project, index) => (
				<>
					<ReorderableSeparator key={`sep-${index}`} index={index} />
					<ReorderableItem key={project.id} index={index}>
						<Card className="cursor-default">
							<CardHeader className="flex flex-row items-center gap-3">
								<ReorderableTrigger asChild>
									<button className="cursor-move p-1 hover:bg-accent rounded">
										<GripVertical className="h-5 w-5 text-muted-foreground" />
									</button>
								</ReorderableTrigger>
								<div>
									<CardTitle>{project.title}</CardTitle>
									<CardDescription>{project.description}</CardDescription>
								</div>
							</CardHeader>
						</Card>
					</ReorderableItem>
				</>
			))}
			<ReorderableSeparator index={projects.length} />
		</ReorderableList>
	)
}

Components

ReorderableList

The root container component that manages drag and drop state.

Props:

  • items: T[] - Array of items to reorder
  • onReorder: (items: T[]) => void - Callback fired when items are reordered
  • className?: string - Optional CSS classes
  • All standard div props

ReorderableItem

Individual draggable item in the list.

Props:

  • index: number - Index of the item in the items array
  • asChild?: boolean - Use Radix Slot to merge props with child element
  • className?: string - Optional CSS classes
  • All standard div props

ReorderableTrigger

The drag handle for an item. Can be placed anywhere within a ReorderableItem.

Props:

  • asChild?: boolean - Use Radix Slot to merge props with child element
  • className?: string - Optional CSS classes (includes cursor-move by default)
  • All standard div props

ReorderableSeparator

Visual drop indicator between items. Place one before each item and one after the last item.

Props:

  • index: number - Position where the drop indicator should appear
  • asChild?: boolean - Use Radix Slot to merge props with child element
  • className?: string - Optional CSS classes
  • All standard div props

Spoke utility classes

ClassFallback chainDescription
--reorderable-separatorvar(--primary)Color for drop indicator between items

Data Attributes

The component provides several data attributes for styling different states:

  • data-reorderable-list - Applied to the list container
  • data-reorderable-item - Applied to each item
  • data-reorderable-trigger - Applied to drag handles
  • data-reorderable-separator - Applied to drop indicators
  • data-dragging="true" - Applied to the item being dragged
  • data-drag-active="true" - Applied to all items when dragging
  • data-drop-target="true" - Applied to items at the drop position

Notes

  • Uses native HTML5 drag and drop API
  • Supports keyboard navigation (drag handles are focusable)
  • Items must have stable keys for proper React reconciliation
  • The onReorder callback is called during drag, not just on drop
  • Separators provide visual feedback for drop position