Search for a command to run...
A drag-and-drop sortable component designed for seamless item reordering with vertical and grid layouts.
npx shadcn@latest add @zaidan/sortablepnpx shadcn add @zaidan/sortableyarn dlx shadcn@latest add @zaidan/sortablebunx shadcn@latest add @zaidan/sortableInstall the following dependencies:
npm i @dnd-kit/solidpnpm add @dnd-kit/solidyarn add @dnd-kit/solidbun add @dnd-kit/solidCopy and paste the following code into your project.
1import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor } from "@dnd-kit/solid";2import { isSortable, useSortable } from "@dnd-kit/solid/sortable";3import type { ComponentProps, JSX, ParentProps, ValidComponent } from "solid-js";4import { createContext, createSignal, mergeProps, Show, splitProps, useContext } from "solid-js";5import { Dynamic } from "solid-js/web";6
7import { cn } from "~/lib/utils";8
9// ---------- Contexts ----------10
11type SortableItemContextValue = {12 setHandleRef: (el: Element | undefined) => void;13 isDragging: () => boolean;14 disabled: () => boolean | undefined;15};16
17const SortableItemContext = createContext<SortableItemContextValue>({18 setHandleRef: () => undefined,19 isDragging: () => false,20 disabled: () => false,21});22
23const IsOverlayContext = createContext(false);24
25type SortableInternalContextValue = {26 activeId: () => string | null;27 // Returns the index of `id` in the current sortable list, or -1 if missing.28 indexOf: (id: string) => number;29};30
31const SortableInternalContext = createContext<SortableInternalContextValue>({32 activeId: () => null,33 indexOf: () => -1,34});35
36// ---------- Drop animation defaults ----------37
38type DropAnimationConfig = {39 duration: number;40 easing: string;41};42
43const defaultDropAnimation: DropAnimationConfig = {44 duration: 250,45 easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",46};47
48// ---------- Types ----------49
50/**51 * Sortable root props. Wraps a list of `<SortableItem />` children inside a52 * `DragDropProvider`. Reordering is performed by mutating the `value` array53 * via `onValueChange` (or by handling `onMove` yourself for custom reorder54 * logic).55 *56 * Notes on porting from React `@dnd-kit/core` + `@dnd-kit/sortable`:57 * - `strategy` is preserved for forward-compat but is a no-op:58 * `@dnd-kit/solid` (v7) auto-detects layout direction.59 * - `modifiers` is forwarded to `DragDropProvider` (and `DragOverlay` via the60 * internal context). `unknown[]` is used because `@dnd-kit/solid` does not61 * re-export the modifier type publicly.62 * - Sensors use `PointerSensor` (covers mouse + touch) and `KeyboardSensor`63 * from `@dnd-kit/solid`.64 */65export type SortableRootProps<T> = Omit<66 JSX.HTMLAttributes<HTMLDivElement>,67 "onDragStart" | "onDragEnd" | "children"68> & {69 value: T[];70 onValueChange: (value: T[]) => void;71 getItemValue: (item: T) => string;72 children?: JSX.Element;73 onMove?: (event: {74 activeIndex: number;75 overIndex: number;76 activeId: string;77 overId: string;78 }) => void;79 /**80 * @deprecated `@dnd-kit/solid` v7 auto-detects layout. Kept for forward81 * compatibility; this prop is currently a no-op.82 */83 strategy?: "horizontal" | "vertical" | "grid";84 onDragStart?: (event: { activeId: string }) => void;85 onDragEnd?: (event: { activeId: string; overId: string | null; canceled: boolean }) => void;86 modifiers?: unknown[];87 as?: ValidComponent;88};89
90export type SortableItemProps = ComponentProps<"div"> & {91 value: string;92 disabled?: boolean;93 as?: ValidComponent;94};95
96export type SortableItemHandleProps = JSX.HTMLAttributes<HTMLDivElement> & {97 cursor?: boolean;98 as?: ValidComponent;99};100
101export type SortableOverlayProps = ParentProps<102 {103 dropAnimation?: DropAnimationConfig | null;104 style?: JSX.CSSProperties;105 } & Omit<ComponentProps<"div">, "style">106>;107
108// ---------- Sortable root ----------109
110/**111 * Sortable root. Provides the `DragDropProvider` and an internal context that112 * descendant items use to look up their reactive index in the current array,113 * and that the overlay reads to know which item is active.114 */115function Sortable<T>(props: SortableRootProps<T>) {116 const [local, others] = splitProps(props, [117 "value",118 "onValueChange",119 "getItemValue",120 "class",121 "onMove",122 "strategy",123 "onDragStart",124 "onDragEnd",125 "modifiers",126 "children",127 "as",128 ]);129
130 const [activeId, setActiveId] = createSignal<string | null>(null);131
132 const handleDragStart = (event: { operation: { source: { id: unknown } } }) => {133 const id = String(event.operation.source.id);134 setActiveId(id);135 local.onDragStart?.({ activeId: id });136 };137
138 const handleDragEnd = (event: {139 canceled: boolean;140 operation: {141 source: { id: unknown } | null;142 target: { id: unknown } | null;143 };144 }) => {145 const source = event.operation.source;146 const target = event.operation.target;147 const sourceId = source && source.id !== undefined ? String(source.id) : null;148 const targetId = target && target.id !== undefined ? String(target.id) : null;149
150 setActiveId(null);151
152 local.onDragEnd?.({153 activeId: sourceId ?? "",154 overId: targetId,155 canceled: event.canceled,156 });157
158 if (event.canceled || !source || !isSortable(source as never)) {159 return;160 }161
162 const sortableSource = source as unknown as { initialIndex: number; index: number };163 const activeIndex = sortableSource.initialIndex;164 const overIndex = sortableSource.index;165
166 if (activeIndex === overIndex) {167 return;168 }169
170 if (local.onMove) {171 local.onMove({172 activeIndex,173 overIndex,174 activeId: sourceId ?? "",175 overId: targetId ?? sourceId ?? "",176 });177 return;178 }179
180 const items = local.value;181 if (182 activeIndex < 0 ||183 activeIndex >= items.length ||184 overIndex < 0 ||185 overIndex >= items.length186 ) {187 return;188 }189
190 const next = items.slice();191 const [moved] = next.splice(activeIndex, 1);192 next.splice(overIndex, 0, moved);193 local.onValueChange(next);194 };195
196 const internalContextValue: SortableInternalContextValue = {197 activeId,198 indexOf: (id) => local.value.findIndex((item) => local.getItemValue(item) === id),199 };200
201 return (202 <SortableInternalContext.Provider value={internalContextValue}>203 <DragDropProvider204 sensors={[PointerSensor, KeyboardSensor]}205 modifiers={local.modifiers as never}206 onDragStart={handleDragStart as never}207 onDragEnd={handleDragEnd as never}208 >209 <Dynamic210 component={local.as ?? "div"}211 data-slot="sortable"212 data-dragging={activeId() !== null ? "" : undefined}213 class={cn(activeId() !== null && "cursor-grabbing!", local.class)}214 {...others}215 >216 {local.children}217 </Dynamic>218 </DragDropProvider>219 </SortableInternalContext.Provider>220 );221}222
223// ---------- Sortable item ----------224
225/**226 * Sortable item. Registers itself with the sortable manager via `useSortable`,227 * passing a reactive `index` getter that reads the current position in the228 * parent `Sortable`'s `value` array.229 *230 * When rendered inside a `<SortableOverlay />` (via `IsOverlayContext`) it231 * skips registration and only renders presentational markup.232 */233function SortableItem(props: SortableItemProps) {234 const isOverlay = useContext(IsOverlayContext);235 const internal = useContext(SortableInternalContext);236
237 const [local, others] = splitProps(props, [238 "value",239 "class",240 "disabled",241 "as",242 "children",243 "ref",244 ]);245
246 if (isOverlay) {247 return (248 <SortableItemContext.Provider249 value={{250 setHandleRef: () => undefined,251 isDragging: () => true,252 disabled: () => false,253 }}254 >255 <Dynamic256 component={local.as ?? "div"}257 data-slot="sortable-item"258 data-value={local.value}259 data-dragging=""260 class={local.class}261 {...others}262 >263 {local.children}264 </Dynamic>265 </SortableItemContext.Provider>266 );267 }268
269 const sortable = useSortable({270 get id() {271 return local.value;272 },273 get index() {274 const idx = internal.indexOf(local.value);275 return idx === -1 ? 0 : idx;276 },277 get disabled() {278 return Boolean(local.disabled);279 },280 });281
282 const itemContextValue: SortableItemContextValue = {283 setHandleRef: (el) => sortable.handleRef(el),284 isDragging: () => sortable.isDragging(),285 disabled: () => local.disabled,286 };287
288 return (289 <SortableItemContext.Provider value={itemContextValue}>290 <Dynamic291 component={local.as ?? "div"}292 ref={sortable.ref}293 data-slot="sortable-item"294 data-value={local.value}295 data-dragging={sortable.isDragging() ? "" : undefined}296 data-disabled={local.disabled ? "" : undefined}297 class={cn(local.class, {298 "z-50 opacity-50": sortable.isDragging(),299 "opacity-50": local.disabled,300 })}301 {...others}302 >303 {local.children}304 </Dynamic>305 </SortableItemContext.Provider>306 );307}308
309// ---------- Sortable item handle ----------310
311/**312 * Optional drag handle for a `SortableItem`. When present, only the handle313 * triggers a drag (instead of the whole item). Wires `handleRef` through the314 * `SortableItemContext`.315 */316function SortableItemHandle(props: SortableItemHandleProps) {317 const merged = mergeProps({ cursor: true }, props);318 const [local, others] = splitProps(merged, ["class", "cursor", "as", "children"]);319 const ctx = useContext(SortableItemContext);320
321 return (322 <Dynamic323 component={local.as ?? "div"}324 ref={(el: Element | undefined) => ctx.setHandleRef(el)}325 data-slot="sortable-item-handle"326 data-dragging={ctx.isDragging() ? "" : undefined}327 data-disabled={ctx.disabled() ? "" : undefined}328 class={cn(329 local.cursor && (ctx.isDragging() ? "cursor-grabbing!" : "cursor-grab!"),330 local.class,331 )}332 {...others}333 >334 {local.children}335 </Dynamic>336 );337}338
339// ---------- Sortable overlay ----------340
341/**342 * Renders the floating preview that follows the cursor while dragging. Pass343 * either static JSX (rendered whenever a drag is active) or a render function344 * `({ value }) => JSX` to render content based on the active item id.345 *346 * `DragOverlay` self-portals — no `<Portal>` wrapper is needed.347 */348function SortableOverlay(props: SortableOverlayProps) {349 const internal = useContext(SortableInternalContext);350 const [local] = splitProps(props, ["children", "class", "dropAnimation", "style"]);351
352 return (353 <DragOverlay354 dropAnimation={local.dropAnimation === undefined ? defaultDropAnimation : local.dropAnimation}355 class={cn("z-50", internal.activeId() && "cursor-grabbing", local.class)}356 style={local.style}357 >358 {() => (359 <IsOverlayContext.Provider value={true}>360 <Show when={internal.activeId() && local.children}>{local.children}</Show>361 </IsOverlayContext.Provider>362 )}363 </DragOverlay>364 );365}366
367export { Sortable, SortableItem, SortableItemHandle, SortableOverlay };import { Sortable, SortableItem, SortableItemHandle, SortableOverlay,} from "~/components/blocks/sortable";<Sortable value={items()} onValueChange={setItems} getItemValue={(item) => item.id}> <For each={items()}> {(item) => ( <SortableItem value={item.id}> <SortableItemHandle> <GripVertical /> </SortableItemHandle> {item.content} </SortableItem> )} </For></Sortable>Here are the source code of all the examples from the preview page:
import FileText from "lucide-solid/icons/file-text";import GripVertical from "lucide-solid/icons/grip-vertical";import ImageIcon from "lucide-solid/icons/image";import Music from "lucide-solid/icons/music";import Video from "lucide-solid/icons/video";import { createSignal, For } from "solid-js";import { toast } from "solid-sonner";import { Badge } from "~/components/ui/badge";import { Sortable, SortableItem, SortableItemHandle } from "~/components/blocks/sortable";function SortableBasic() { const [items, setItems] = createSignal<FileItem[]>(defaultFileItems);
return ( <Example title="Basic"> <Sortable value={items()} getItemValue={(item) => item.id} class="mx-auto w-full max-w-xl space-y-2" onValueChange={(newItems) => { setItems(newItems); toast.success("Items reordered successfully!", { description: newItems.map((item, index) => `${index + 1}. ${item.title}`).join(", "), }); }} > <For each={items()}> {(item) => ( <SortableItem value={item.id}> <div class="flex items-center gap-3 rounded-md border border-border bg-background p-3 transition-colors hover:bg-accent/50"> <SortableItemHandle class="text-muted-foreground hover:text-foreground"> <GripVertical class="h-4 w-4" /> </SortableItemHandle>
<div class="flex items-center gap-2 text-muted-foreground"> {getTypeIcon(item.type)} </div>
<div class="min-w-0 flex-1"> <h4 class="truncate font-medium text-sm">{item.title}</h4> <p class="truncate text-muted-foreground text-xs">{item.description}</p> </div>
<div class="flex items-center gap-2"> <Badge variant={getTypeVariant(item.type)}>{item.type}</Badge> <span class="text-muted-foreground text-xs">{item.size}</span> </div> </div> </SortableItem> )} </For> </Sortable> </Example> );}import GripVertical from "lucide-solid/icons/grip-vertical";import { createSignal, For } from "solid-js";import { toast } from "solid-sonner";import { Badge } from "~/components/ui/badge";import { Sortable, SortableItem, SortableItemHandle } from "~/components/blocks/sortable";function SortableGrid() { const [items, setItems] = createSignal<GridItem[]>(defaultGridItems);
return ( <Example title="Grid"> <Sortable value={items()} onValueChange={(newItems) => { setItems(newItems); toast.success("Grid items reordered successfully!", { description: `New order: ${newItems .map((item, index) => `${index + 1}. ${item.title}`) .join(", ")}`, }); }} getItemValue={(item) => item.id} class="mx-auto grid w-full max-w-2xl auto-rows-fr grid-cols-3 gap-3" > <For each={items()}> {(item) => ( <SortableItem value={item.id}> <div class="group relative flex min-h-25 flex-col rounded-md border border-border bg-background p-3 transition-colors hover:bg-accent/50"> <SortableItemHandle class="absolute inset-e-1.5 top-2.5 z-10 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"> <GripVertical class="h-3.5 w-3.5" /> </SortableItemHandle>
<div class="min-w-0 flex-1"> <h4 class="truncate font-medium text-sm">{item.title}</h4> <p class="mt-0.5 truncate text-muted-foreground text-xs">{item.description}</p> </div>
<div class="mt-2 flex items-center justify-between"> <Badge variant={getGridVariant(item.type)}>{item.type}</Badge> <span class="text-muted-foreground text-xs">{item.size}</span> </div> </div> </SortableItem> )} </For> </Sortable> </Example> );}import GripVertical from "lucide-solid/icons/grip-vertical";import { createSignal, For } from "solid-js";import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "~/components/ui/card";import { Sortable, SortableItem, SortableItemHandle } from "~/components/blocks/sortable";import { Switch } from "~/components/ui/switch";function SortableWithSwitch() { const [channels, setChannels] = createSignal<NotificationChannel[]>(defaultChannels);
const toggleChannel = (id: string) => { setChannels((prev) => prev.map((ch) => (ch.id === id ? { ...ch, enabled: !ch.enabled } : ch))); };
return ( <Example title="Settings Priority"> <Card class="mx-auto w-full max-w-md"> <CardHeader> <CardTitle>Notification Priority</CardTitle> <CardDescription> Drag to reorder by priority. Top channels are tried first. </CardDescription> </CardHeader> <CardContent> <Sortable value={channels()} onValueChange={setChannels} getItemValue={(item) => item.id} class="space-y-1" > <For each={channels()}> {(channel) => ( <SortableItem value={channel.id}> <div class="flex items-center gap-3 rounded-md border px-3 py-2.5"> <SortableItemHandle class="text-muted-foreground hover:text-foreground"> <GripVertical class="size-4" /> </SortableItemHandle> <div class="min-w-0 flex-1"> <p class="font-medium text-sm">{channel.name}</p> <p class="text-muted-foreground text-xs">{channel.description}</p> </div> <Switch checked={channel.enabled} onChange={() => toggleChannel(channel.id)} /> </div> </SortableItem> )} </For> </Sortable> </CardContent> </Card> </Example> );}The root component that manages the sortable state and the drag-and-drop context. Wraps a list of <SortableItem /> children inside a DragDropProvider from @dnd-kit/solid.
| Prop | Type | Default | Description |
|---|---|---|---|
value | T[] | - | Required. The array of items to sort. |
onValueChange | (value: T[]) => void | - | Required. Callback fired with the reordered array. Not called when onMove is provided. |
getItemValue | (item: T) => string | - | Required. Function to extract a unique string id from an item. |
onMove | (event: { activeIndex: number; overIndex: number; activeId: string; overId: string }) => void | - | Optional handler for custom reorder logic. When provided, onValueChange is not called automatically. |
onDragStart | (event: { activeId: string }) => void | - | Callback fired when a drag operation begins. |
onDragEnd | (event: { activeId: string; overId: string | null; canceled: boolean }) => void | - | Callback fired when a drag operation ends. |
modifiers | unknown[] | - | Modifiers forwarded to DragDropProvider (and DragOverlay via internal context). |
strategy | "horizontal" | "vertical" | "grid" | - | (deprecated, no-op) @dnd-kit/solid v7 auto-detects layout. Kept for forward compatibility only. |
as | ValidComponent | "div" | The element or component to render as. |
class | string | - | Additional CSS classes for the container. |
An individual draggable item within the sortable list. Registers itself with the sortable manager via useSortable. When rendered inside <SortableOverlay />, registration is skipped and only presentational markup is rendered.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | Required. The unique identifier for the item. |
disabled | boolean | false | Whether the item is disabled (not draggable). |
as | ValidComponent | "div" | The element or component to render as. |
class | string | - | Additional CSS classes for the item. |
The optional drag handle for an individual sortable item. When present, only the handle triggers a drag (instead of the whole item).
| Prop | Type | Default | Description |
|---|---|---|---|
cursor | boolean | true | Whether to apply cursor-grab / cursor-grabbing styles to the handle automatically. |
as | ValidComponent | "div" | The element or component to render as. |
class | string | - | Additional CSS classes for the handle. |
Renders the floating preview that follows the cursor while dragging. Pass either static JSX (rendered whenever a drag is active) or a render function ({ value }) => JSX to render content based on the active item id. DragOverlay self-portals — no <Portal> wrapper is needed.
| Prop | Type | Default | Description |
|---|---|---|---|
children | JSX.Element | ((params: { value: string }) => JSX.Element) | - | Static JSX or a render function that receives the active item id. |
dropAnimation | { duration: number; easing: string } | null | { duration: 250, easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)" } | Drop animation config. Pass null to disable. |
modifiers | unknown[] | - | Modifiers forwarded to the overlay. |
class | string | - | Additional CSS classes for the overlay container. |