Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.7K
Sign inGet All-Access
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Embed
  • Agent Skills
  • llms.txt
  • RTL
  • Changelogv2.8.1
  • YouTube
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • Grok
  • Conductor
  • v0
  • Lovable
  • Replit
  • Bolt
  • OpenCode
  • VS Code
  • GitHub Copilot
  • Kilo Code
  • Zed
  • Antigravity
  • Gemini CLI
Components
  • Alert
  • Autocomplete
  • Badge
  • CascaderCascader keeps a saved selection's full path, popup open or closed
  • Code Block
  • Color Picker
  • Data GridData Grid works in apps that run React Compiler, with resizing, edits and callbacks kept live
  • Date SelectorDate Selector keeps its year list and Today button current in apps that run React Compiler
  • Emoji Picker
  • Event CalendarEvent Calendar keeps today and the agenda current in apps that run React Compiler
  • File TypeFile Type knows 171 formats, .env and Dockerfile included
  • File Upload
  • Filters
  • Frame
  • GanttGantt keeps its Today button and tooltip current in apps that run React Compiler
  • Icon Stack
  • Icon Tile
  • Kanban
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • Signature Pad
  • Sortable
  • Stepper
  • Time Picker
  • Timeline
  • Tree

Application

  • App Shell
  • Auth
  • Card
  • Dashboard
  • Dialog
  • Empty State
  • Event Calendar
  • Flow
  • Form
  • Gantt
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Rich Text Editor
  • Schedule
  • Settings
  • Sheet
  • Stats2 meter cards moved here from Charts, 17 in all
  • Timeline
  • Whiteboard
  • Wizard

Data Grid

  • Base
  • AdvancedAdded 2 new Data Grid Advanced blocks, 5 in all
  • Columns
  • Drag & Drop
  • Editing
  • Expansion
  • Filtering
  • Grouping
  • Virtualization

Charts

  • Area ChartAdded a new Inbound Leads area chart block
  • Bar Chart
  • Composed Chart
  • Line Chart
  • Pie Chart
  • Radial Chart
  • Sankey ChartNew Sankey Chart category with 3 flow chart blocks
  • Slope ChartNew Slope Chart category with 2 rank-shift chart blocks
  • Sparkline
  • Status Tracker

AI & Agents

  • AI Chat
  • Agent Activity
  • AI UsageNew AI Usage category with 2 spend blocks

Solutions

  • Dev Ops
  • AI Ops
  • CRM
  • Agents
  • Analytics
  • Billing
  • Bookings
  • Files
  • Inventory
  • Users
  • Digital TwinNew Digital Twin category with 2 interactive 3D blocks

Templates

  • All templates

eCommerce

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

Marketing

  • Blog
  • Compare
  • Contact
  • CTA
  • FAQ
  • Hero
  • How It Works
  • Pricing

Resources

  • Components
  • Blocks
  • Icons
  • MCP for Agents
  • Docs
  • Support
  • Pricing
  • Tailwind Plus Alternative
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

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

© 2026 ReUI. All rights reserved.

3.7K

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

Signature PadStepper

On This Page

InstallationUsageFor AI AgentsExamplesGridNestedPersist 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>

For AI Agents

Hand your agent this brief or the ReUI MCP, not a screenshot. Free to install.

When to use

  • Ranked lists such as a play queue, notification channels or release tasks, put in order by a grip
  • Tile grids such as a media library, where cards change places across rows and columns
  • Grouped options and menus reordered in place, such as product variants or sidebar menu entries

When not to use

  • Cards that move between several status columns on one board: use the Kanban component
  • Ranked table rows that keep several columns of values lined up: use the Drag & Drop blocks
  • A whole page layout of sidebar, top bar and content area around the list: use the App Shell blocks

Agent notes

  • Drag listeners sit on SortableItemHandle only, so a SortableItem rendered without one cannot be dragged
  • Passing onMove skips the built-in reorder, so neither onValueChange nor onValueCommit fires on drop
  • The drag preview clones the child of Sortable whose value matches the dragged item, so a wrapper around SortableItem needs that value too

Third-party API docs

  • dnd kit v6: API reference, docs (these are the Legacy docs; dndkit.com's default docs cover the newer @dnd-kit/react, a different API)
  • Radix UI v1: docs
Markdownhttps://web.dev.reui.io/docs/components/radix/sortable.md
MCPget_component("sortable")
Installnpx shadcn@latest add @reui/sortable
Full index for agents:llms.txtReUI MCP setup

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 "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 {
  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