Custom Shadcn Sortable for React and Tailwind CSS. A drag-and-drop sortable component designed for seamless item reordering with vertical, grid, and nested layouts.
import {
Sortable,
SortableItem,
SortableItemHandle,
} from "@/components/byosui/r-sortable"<Sortable
value=
Browse 8 production-ready Shadcn Sortable components for dashboards, forms, and product UI. These examples follow the Radix UI implementation with accessible primitives from the Radix stack and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 8 Shadcn Sortable components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.
Sortable commits once, on drop, so onValueChange alone is already enough to
persist the whole array. When you want index-based mutations and a one-line
rollback, use onValueCommit: it fires with the reordered array and a
previousValue snapshot.
const reorder = api.list.reorder.useMutation()
<Sortable
value={items}
onValueChange={setItems}
getItemValue={(item) => item.id}
onValueCommit={(next, meta) => {
reorder.mutate(
{ id: next[meta.overIndex].id, toIndex: meta.overIndex },
{
onError: () => {
setItems(meta.previousValue) // roll back
toast.error("Could not save the new order. Restored.")
},
},
)
}}
>
{/* ... */}
</Sortable>The root component that manages the sortable state and drag-and-drop context.
SortableCommitMeta<T> is { event: DragEndEvent; activeIndex: number; overIndex: number; previousValue: T[] }.
An individual draggable item within the sortable list.
The drag handle for an individual sortable item.
"use client"
import { useState } from "react"
import { Badge } from "@/components/byosui/badge"
import {
Sortable,
SortableItem,
SortableItemHandle,
} from "@/components/byosui/sortable"
import { toast } from "sonner"
import { cn } from "cn"
import { GripVerticalIcon } from 'lucide-react'
interface GridItem {
id: string
title: string
description: string
type: "image" | "document" | "audio" | "video" | "featured"
size: string
priority: "high" | "medium" | "low"
}
const defaultGridItems: GridItem[] = [
{
id: "1",
title: "Hero Image",
description: "Main banner image",
type: "image",
size: "2.4 MB",
priority: "high",
},
{
id: "2",
title: "Product Specs",
description: "Technical documentation",
type: "document",
size: "1.2 MB",
priority: "medium",
},
{
id: "3",
title: "Demo Video",
description: "Product demonstration",
type: "video",
size: "15.7 MB",
priority: "high",
},
{
id: "4",
title: "Audio Guide",
description: "Voice instructions",
type: "audio",
size: "8.3 MB",
priority: "low",
},
{
id: "5",
title: "Gallery Photo 1",
description: "Product view 1",
type: "image",
size: "3.1 MB",
priority: "medium",
},
{
id: "6",
title: "Gallery Photo 2",
description: "Product view 2",
type: "image",
size: "2.8 MB",
priority: "medium",
},
{
id: "7",
title: "User Manual",
description: "Installation guide",
type: "document",
size: "4.2 MB",
priority: "high",
},
{
id: "8",
title: "Background Music",
description: "Ambient soundtrack",
type: "audio",
size: "12.1 MB",
priority: "low",
},
{
id: "9",
title: "Feature Highlight",
description: "Key product features",
type: "featured",
size: "N/A",
priority: "high",
},
]
const getTypeColor = (type: GridItem["type"]) => {
switch (type) {
case "image":
return "primary-light"
case "document":
return "success-light"
case "audio":
return "destructive-light"
case "video":
return "info-light"
case "featured":
return "warning-light"
}
}
const getItemSize = (type: GridItem["type"]) => {
switch (type) {
case "featured":
return "col-span-2 row-span-2"
case "image":
case "video":
return "col-span-1 row-span-1"
case "document":
case "audio":
return "col-span-1 row-span-1"
default:
return "col-span-1 row-span-1"
}
}
export function Pattern() {
const [items, setItems] = useState<GridItem[]>(defaultGridItems)
const handleValueChange = (newItems: GridItem[]) => {
setItems(newItems)
// Show toast with new order
toast.success("Grid items reordered successfully!", {
description: `New order: ${newItems.map((item, index) => `${index + 1}. ${item.title}`).join(", ")}`,
})
}
const getItemValue = (item: GridItem) => item.id
return (
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
<Sortable
value={items}
onValueChange={handleValueChange}
getItemValue={getItemValue}
strategy="grid"
className="grid auto-rows-fr grid-cols-3 gap-3"
>
{items.map((item) => (
<SortableItem key={item.id} value={item.id}>
<div
className={cn(
"group bg-background border-border hover:bg-accent/50 rounded-md relative cursor-pointer border p-3 transition-colors",
getItemSize(item.type),
"flex min-h-[100px] flex-col"
)}
onClick={() => {}}
>
<SortableItemHandle className="text-muted-foreground hover:text-foreground absolute end-1.5 top-2.5 z-10 opacity-0 transition-opacity group-hover:opacity-100">
<GripVerticalIcon className="h-3.5 w-3.5" />
</SortableItemHandle>
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium">{item.title}</h4>
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{item.description}
</p>
</div>
<div className="mt-2 flex items-center justify-between">
<Badge variant={getTypeColor(item.type)} size="sm">
{item.type}
</Badge>
{item.type !== "featured" && (
<span className="text-muted-foreground text-xs">
{item.size}
</span>
)}
</div>
</div>
</SortableItem>
))}
</Sortable>
</div>
)
}
"use client"
import { useState } from "react"
import { Badge } from "@/components/byosui/badge"
import {
Sortable,
SortableItem,
SortableItemHandle,
type SortableCommitMeta,
} from "@/components/byosui/sortable"
import { toast } from "sonner"
import { GripVerticalIcon } from 'lucide-react'
interface Item {
id: string
title: string
}
const defaultItems: Item[] = [
{ id: "1", title: "Draft the release notes" },
{ id: "2", title: "Review open pull requests" },
{ id: "3", title: "Update the changelog" },
{ id: "4", title: "Cut the release tag" },
{ id: "5", title: "Announce on the blog" },
]
// Simulated backend. Swap for a tRPC mutation or fetch in your app. Rejects
// roughly one in four calls so the optimistic rollback is easy to see.
function persistOrder(meta: SortableCommitMeta<Item>): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.25) {
reject(new Error("Network error"))
} else {
resolve()
}
}, 700)
})
}
export function Pattern() {
const [items, setItems] = useState<Item[]>(defaultItems)
// Sortable commits once, on drop. `onValueChange` has already applied the
// new order optimistically; meta.previousValue is the order before the drag.
const handleValueCommit = (next: Item[], meta: SortableCommitMeta<Item>) => {
const previous = meta.previousValue
const moved = next[meta.overIndex]
toast.promise(persistOrder(meta), {
loading: "Saving order...",
success: () => `Saved "${moved.title}" at position ${meta.overIndex + 1}`,
error: () => {
// Roll back to the pre-drag order. In production prefer a refetch here
// so a newer drag is not clobbered by this snapshot.
setItems(previous)
return "Could not save the new order. Restored."
},
})
}
return (
<div className="mx-auto w-full max-w-xl p-6">
<Sortable
value={items}
onValueChange={setItems}
onValueCommit={handleValueCommit}
getItemValue={(item) => item.id}
strategy="vertical"
className="space-y-2"
>
{items.map((item, index) => (
<SortableItem key={item.id} value={item.id}>
<div className="bg-background border-border flex items-center gap-3 rounded-md border p-3">
<SortableItemHandle className="text-muted-foreground hover:text-foreground">
<GripVerticalIcon className="h-4 w-4" />
</SortableItemHandle>
<Badge variant="outline" className="tabular-nums">
{index + 1}
</Badge>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{item.title}
</span>
</div>
</SortableItem>
))}
</Sortable>
</div>
)
}
"use client"
import { useState } from "react"
import {
Sortable,
SortableItem,
SortableItemHandle,
} from "@/components/byosui/sortable"
import { toast } from "sonner"
import { Card, CardContent } from "@/components/ui/card"
import { GripVerticalIcon } from 'lucide-react'
interface OptionValue {
id: string
value: string
}
interface OptionGroup {
id: string
name: string
values: OptionValue[]
}
const defaultOptionGroups: OptionGroup[] = [
{
id: "1",
name: "Colors",
values: [
{ id: "1-1", value: "White" },
{ id: "1-2", value: "Black" },
{ id: "1-3", value: "Grey" },
{ id: "1-4", value: "Green" },
],
},
{
id: "2",
name: "Sizes",
values: [
{ id: "2-1", value: "Small" },
{ id: "2-2", value: "Medium" },
{ id: "2-3", value: "Large" },
],
},
{
id: "3",
name: "Materials",
values: [
{ id: "3-1", value: "Cotton" },
{ id: "3-2", value: "Polyester" },
{ id: "3-3", value: "Wool" },
],
},
]
export function Pattern() {
const [optionGroups, setOptionGroups] =
useState<OptionGroup[]>(defaultOptionGroups)
const handleParentReorder = (newGroups: OptionGroup[]) => {
setOptionGroups(newGroups)
toast.success("Option groups reordered successfully!", {
description: `${newGroups.map((group, index) => `${index + 1}. ${group.name}`).join(", ")}`,
})
}
const getParentValue = (group: OptionGroup) => group.id
const getChildValue = (value: OptionValue) => value.id
const handleChildReorder = (groupId: string, newValues: OptionValue[]) => {
setOptionGroups((prev) =>
prev.map((group) =>
group.id === groupId ? { ...group, values: newValues } : group
)
)
toast.success("Values reordered successfully!", {
description: newValues
.map((value, index) => `${index + 1}. ${value.value}`)
.join(", "),
})
}
return (
<div className="mx-auto w-full max-w-sm space-y-6 p-6">
<Sortable
value={optionGroups}
onValueChange={handleParentReorder}
getItemValue={getParentValue}
strategy="vertical"
className="space-y-4"
>
{optionGroups.map((group) => (
<SortableItem key={group.id} value={group.id}>
<Card className="p-2">
<CardContent className="p-0">
{/* Group Header */}
<div className="mb-2 flex items-center gap-2">
<SortableItemHandle className="text-muted-foreground hover:text-foreground cursor-grab">
<GripVerticalIcon className="h-4 w-4" />
</SortableItemHandle>
<h3 className="text-sm font-semibold">{group.name}</h3>
</div>
{/* Option Values - Child Level */}
<Sortable
value={group.values}
onValueChange={(newValues) =>
handleChildReorder(group.id, newValues)
}
getItemValue={getChildValue}
strategy="vertical"
className="space-y-2"
>
{group.values.map((value) => (
<SortableItem key={value.id} value={value.id}>
<div className="border-border rounded-md flex items-center gap-2 border p-1.5">
<SortableItemHandle className="text-muted-foreground hover:text-foreground cursor-grab">
<GripVerticalIcon className="h-4 w-4" />
</SortableItemHandle>
<span className="flex-1 text-sm">{value.value}</span>
</div>
</SortableItem>
))}
</Sortable>
</CardContent>
</Card>
</SortableItem>
))}
</Sortable>
</div>
)
}
"use client"
import { useState } from "react"
import { Badge } from "@/components/byosui/badge"
import {
Sortable,
SortableItem,
SortableItemHandle,
} from "@/components/byosui/sortable"
import { toast } from "sonner"
import { FileTextIcon, GripVerticalIcon, ImageIcon, MusicIcon, VideoIcon } from 'lucide-react'
interface SortableItem {
id: string
title: string
description: string
type: "image" | "document" | "audio" | "video"
size: string
}
const defaultItems: SortableItem[] = [
{
id: "1",
title: "Product Demo",
description: "Main product image",
type: "image",
size: "2.4 MB",
},
{
id: "2",
title: "Product Specification",
description: "Technical details document",
type: "document",
size: "1.2 MB",
},
{
id: "3",
title: "Product Demo Video",
description: "How to use the product",
type: "video",
size: "15.7 MB",
},
{
id: "4",
title: "Product Audio Guide",
description: "Audio instructions",
type: "audio",
size: "8.3 MB",
},
{
id: "5",
title: "Product Specification",
description: "Additional product view",
type: "image",
size: "3.1 MB",
},
]
const getTypeIcon = (type: SortableItem["type"]) => {
switch (type) {
case "image":
return (
<ImageIcon className="h-4 w-4" />
)
case "document":
return (
<FileTextIcon className="h-4 w-4" />
)
case "audio":
return (
<MusicIcon className="h-4 w-4" />
)
case "video":
return (
<VideoIcon className="h-4 w-4" />
)
}
}
const getTypeColor = (type: SortableItem["type"]) => {
switch (type) {
case "image":
return "primary-light"
case "document":
return "success-light"
case "audio":
return "destructive-light"
case "video":
return "info-light"
}
}
export function Pattern() {
const [items, setItems] = useState<SortableItem[]>(defaultItems)
const handleValueChange = (newItems: SortableItem[]) => {
setItems(newItems)
// Show toast with new order
toast.success("Items reordered successfully!", {
description: newItems
.map((item, index) => `${index + 1}. ${item.title}`)
.join(", "),
})
}
const getItemValue = (item: SortableItem) => item.id
return (
<div className="mx-auto w-full max-w-xl space-y-8 p-6">
<Sortable
value={items}
onValueChange={handleValueChange}
getItemValue={getItemValue}
strategy="vertical"
className="space-y-2"
>
{items.map((item) => (
<SortableItem key={item.id} value={item.id}>
<div
className="bg-background border-border hover:bg-accent/50 rounded-md flex cursor-pointer items-center gap-3 border p-3 transition-colors"
onClick={() => {}}
>
<SortableItemHandle className="text-muted-foreground hover:text-foreground">
<GripVerticalIcon className="h-4 w-4" />
</SortableItemHandle>
<div className="text-muted-foreground flex items-center gap-2">
{getTypeIcon(item.type)}
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium">{item.title}</h4>
<p className="text-muted-foreground truncate text-xs">
{item.description}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={getTypeColor(item.type)}>{item.type}</Badge>
<span className="text-muted-foreground text-xs">
{item.size}
</span>
</div>
</div>
</SortableItem>
))}
</Sortable>
</div>
)
}