Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.3K
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Agent Skills
  • RTL
  • Changelog
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • Grok
  • Conductor
  • v0
  • Lovable
  • Replit
  • Bolt
  • OpenCode
  • VS Code
  • GitHub Copilot
  • Kilo Code
  • Zed
  • Antigravity
Components
  • Alert
  • Autocomplete
  • Badge
  • Data GridRebuilt on TanStack Table v9, with pinning now start/end
  • Date Selector
  • Event CalendarNew event calendar component with five views
  • File Upload
  • FiltersSizing and interaction refinements
  • Frame
  • GanttNew gantt component with day to year scales
  • Icon Stack
  • Icon TileNew icon tile component with five surface variants
  • KanbanonValueCommit callback and accessibility improvements
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • SortableonValueCommit callback and accessibility improvements
  • StepperRender prop composition and styling refinements
  • Timeline
  • Tree

Application

  • App Shell
  • Auth
  • Card
  • Chart
  • Dashboard
  • Dialog
  • Empty State
  • Event CalendarThree new event calendar blocks added
  • Form
  • GanttNew gantt block added
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Schedule
  • Settings
  • Sheet
  • Stats
  • Timeline
  • Wizard

Solutions

  • Agents
  • AI Ops
  • Analytics
  • Billing
  • Bookings
  • CRM
  • Files
  • Inventory
  • Users

Templates

  • E-commerce
  • SaaS
  • Dashboard
  • Landing
  • All templates

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist

Data Grid

  • Base
  • Columns
  • Drag & DropNew drag and drop Data Grid block added
  • EditingNew editable Data Grid block added
  • Expansion
  • Filtering
  • GroupingFour new grouped and tree Data Grid blocks added
  • Virtualization

Marketing

  • Blog
  • Contact
  • CTA
  • FAQ

Resources

  • Components
  • Blocks
  • Docs
  • Support
  • Pricing
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026 ReUI. All rights reserved.

3.3K

Shadcn Sortable

PreviousNext

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.

Base UIRadix UI

Installation

pnpm dlx shadcn@latest add @reui/sortable

Usage

import {
  Sortable,
  SortableItem,
  SortableItemHandle,
} from "@/components/reui/sortable"

Shadcn Sortable Free Components

Browse 8 production-ready Shadcn Sortable components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react 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.

ScrollspyStepper

On This Page

InstallationUsageExamplesGridNestedPersist to a backendPersisting orderAPI ReferenceSortableSortableItemSortableItemHandle
<Sortable value={items} onValueChange={setItems} getItemValue={(item) => item.id} > {items.map((item) => ( <SortableItem key={item.id} value={item.id}> <SortableItemHandle> <GripVertical /> </SortableItemHandle> {item.content} </SortableItem> ))} </Sortable>

Examples

Grid

Nested

Persist to a backend

"use client"

import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  Sortable,
  SortableItem,
  SortableItemHandle,
  type SortableCommitMeta,
} from "@/components/reui/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>
  )
}

Persisting order

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>

API Reference

Sortable

The root component that manages the sortable state and drag-and-drop context.

PropTypeDefaultDescription
valueT[]-Required. The array of items to sort.
onValueChange(value: T[]) => void-Required. Fired once, on drop, with the reordered array. This alone is enough to persist the whole array.
getItemValue(item: T) => string-Required. Function to extract a unique ID from an item.
onValueCommit(value: T[], meta: SortableCommitMeta<T>) => void-Fired on drop with the reordered array and a previousValue snapshot. Convenient for index-based backend mutations and one-line rollback. See .
onMove(event: { event: DragEndEvent; activeIndex: number; overIndex: number }) => void-Opt-in. When set, replaces the default onValueChange reorder so you apply the move yourself.
strategy"horizontal" | "vertical" | "grid""vertical"The sorting strategy and layout of the list.
onDragStart(event: DragStartEvent) => void-Raw dnd-kit passthrough, fired when a drag starts.
onDragEnd(event: DragEndEvent) => void-Raw dnd-kit passthrough, fired before the reorder is applied (so onValueChange is the persistence seam, not this).
onDragCancel(event: DragCancelEvent) => void-Raw dnd-kit passthrough, fired when a drag is cancelled.
accessibilityDndContextProps["accessibility"]-dnd-kit accessibility options (announcements and screen reader instructions).
modifiersModifiers-dnd-kit modifiers applied to the drag.
classNamestring-Additional CSS classes for the container.

SortableCommitMeta<T> is { event: DragEndEvent; activeIndex: number; overIndex: number; previousValue: T[] }.


SortableItem

An individual draggable item within the sortable list.

PropTypeDefaultDescription
valuestring-Required. The unique identifier for the item.
disabledbooleanfalseWhether the item is draggable.
classNamestring-Additional CSS classes for the item.

SortableItemHandle

The drag handle for an individual sortable item.

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the handle.
"use client"

import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  Sortable,
  SortableItem,
  SortableItemHandle,
} from "@/components/reui/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>
  )
}
"use client"

import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  Sortable,
  SortableItem,
  SortableItemHandle,
} from "@/components/reui/sortable"
import { toast } from "sonner"

import { cn } from "@/lib/utils"
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 {
  Sortable,
  SortableItem,
  SortableItemHandle,
} from "@/components/reui/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>
  )
}
Persisting order