eglador-ui-react-sortable
v1.0.0-alpha.1
Published
React Sortable / Repeater component for Eglador UI — drag-and-drop reorder with PayloadCMS-style row UI
Downloads
114
Maintainers
Readme
eglador-ui-react-sortable
A drag-and-drop sortable / repeater field for React — PayloadCMS-style row UI on top of @dnd-kit. Compound API, headless useSortableList hook, vertical / horizontal / grid orientations, full keyboard accessibility, Tailwind CSS v4.
Features
- Compound API —
<Sortable>+<Sortable.Item>+ opt-in row chrome (Radix / shadcn pattern) - PayloadCMS-style row UI — drag handle on the left, title in the middle, three-dot action dropdown, collapse chevron on the right
- Standard row menu set — 7 opt-in menu items (Move up / Move down / Add below / Duplicate / Copy line / Paste line / Remove), each auto-disabled when not applicable
- 3 orientations —
vertical(list),horizontal(chips / tags),grid(any-direction tiles) - Sensible defaults — pointer + touch + keyboard sensors, 4 px activation distance, 150 ms touch delay, axis lock, smooth transitions
- Modifiers built-in —
restrictToAxis,restrictToBounds, plus pass any custom@dnd-kitmodifier - Disabled rows — lock individual items in place
- Render-prop API — read live
index/isDragging/isOver/collapsedper row - Headless
useSortableListhook — same state model as the component, drive externally withmove/add/remove/duplicate/update - Accessible — keyboard support via
@dnd-kit'sKeyboardSensor, ARIA-labeled buttons, focus rings - TypeScript-first — generic over your item shape, every prop documented inline
@dnd-kit/*bundled — zero extra peer dependencies beyondreact/react-dom/tailwindcss
Installation
npm install eglador-ui-react-sortablePeer dependencies: react >= 18 · react-dom >= 18 · tailwindcss ^4
Setup
Add the following to your global stylesheet so Tailwind picks up the component classes:
@import "tailwindcss";
@source "../node_modules/eglador-ui-react-sortable";The @source path is relative to the CSS file location:
| Framework | CSS file location | Path |
|---|---|---|
| Next.js (App Router) | app/globals.css | ../node_modules/eglador-ui-react-sortable |
| Next.js (src/) | src/app/globals.css | ../../node_modules/eglador-ui-react-sortable |
| Vite | src/index.css | ../node_modules/eglador-ui-react-sortable |
Quick Start
"use client";
import { Sortable, useSortableList } from "eglador-ui-react-sortable";
interface Section { id: string; title: string; body: string; }
export function PageBuilder() {
const list = useSortableList<Section>({
initial: [
{ id: "1", title: "Hero", body: "" },
{ id: "2", title: "Features", body: "" },
{ id: "3", title: "Pricing", body: "" },
],
});
return (
<>
<Sortable
items={list.items}
onChange={list.setItems}
createItem={() => ({ title: "New section", body: "" })}
>
{list.items.map((row) => (
<Sortable.Item key={row.id} id={row.id} defaultCollapsed>
<Sortable.Row>
<Sortable.Header>
<Sortable.Handle />
<Sortable.Title>{row.title}</Sortable.Title>
<Sortable.Menu>
<Sortable.MoveUpItem />
<Sortable.MoveDownItem />
<Sortable.AddBelowItem />
<Sortable.MenuSeparator />
<Sortable.DuplicateItem />
<Sortable.CopyLineItem />
<Sortable.PasteLineItem />
<Sortable.MenuSeparator />
<Sortable.RemoveItem />
</Sortable.Menu>
<Sortable.CollapseTrigger />
</Sortable.Header>
<Sortable.Content>
<textarea
value={row.body}
onChange={(e) => list.update(row.id, { body: e.target.value })}
/>
</Sortable.Content>
</Sortable.Row>
</Sortable.Item>
))}
</Sortable>
<Sortable.AddButton onClick={() => list.add({ title: "New section", body: "" })} />
</>
);
}API
Exports
| Export | Purpose |
|---|---|
| Sortable | Compound root — wraps DndContext + SortableContext, configures sensors / orientation / modifiers |
| Sortable.Item | One row — owns the useSortable hook, supports disabled + collapse + render-prop |
| Sortable.Handle | Drag handle button |
| Sortable.Row / .Header / .Title / .Toolbar / .Content | PayloadCMS-style row chrome |
| Sortable.CollapseTrigger | Toggles collapsed on the parent Sortable.Item (renders on the far right) |
| Sortable.Menu / .MenuItem / .MenuSeparator | Three-dot action dropdown (outside-click + Escape close) |
| Sortable.MoveUpItem / .MoveDownItem / .AddBelowItem / .DuplicateItem / .CopyLineItem / .PasteLineItem / .RemoveItem | Pre-built menu items wired to context actions, auto-disabled when not applicable |
| Sortable.DuplicateButton / .DeleteButton / .AddButton | Pre-styled action buttons (alternative to Sortable.Menu for a flat toolbar) |
| Sortable.Empty | No-rows placeholder |
| useSortableList() | Headless state hook — items, setItems, move, add, remove, duplicate, update, reset, indexOf |
| useSortableRootContext() / useSortableItemContext() | Read context inside custom subcomponents |
Sortable props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | T[] | — | Array of items, each with a stable id |
| onChange | (items: T[]) => void | — | Fires after every reorder with the new array |
| onMove | (event) => void | — | Fires alongside onChange with { activeId, overId, oldIndex, newIndex } |
| onDragStart | (id) => void | — | Fires when a drag begins |
| onDragEnd | (id) => void | — | Fires when a drag ends (regardless of reorder) |
| orientation | "vertical" \| "horizontal" \| "grid" | "vertical" | Sorting strategy + default axis lock |
| disabled | boolean | false | Disable sorting for the entire list |
| restrictToAxis | boolean | true | Lock the drag preview to the active axis (vertical / horizontal only) |
| restrictToBounds | boolean | false | Keep the drag preview inside the parent container |
| collisionDetection | CollisionDetection | closestCenter / closestCorners | Override @dnd-kit collision algorithm |
| modifiers | Modifier[] | — | Extra @dnd-kit/modifiers to chain after the built-in ones |
| createItem | (atIndex: number) => Omit<T, "id"> | — | Factory used by Sortable.AddBelowItem — required to enable that menu item |
| generateId | () => string \| number | crypto.randomUUID fallback | ID generator for Add below / Duplicate / Paste line |
| dndContextProps | Partial<DndContextProps> | — | Escape hatch for extra DndContext props |
| className | string | — | Class on the wrapping <div> |
| contentClassName | string | — | Class on the inner items container (overrides default flex / grid layout) |
Sortable.Item props
| Prop | Type | Description |
|---|---|---|
| id | string \| number | Unique stable identifier |
| disabled | boolean | Lock this row in place |
| defaultCollapsed | boolean | Initial collapsed state (uncontrolled) |
| collapsed | boolean | Controlled collapsed state |
| onCollapsedChange | (collapsed: boolean) => void | Fires whenever the collapse state toggles |
| children | ReactNode \| (state) => ReactNode | Static markup, or a render-prop receiving { index, isDragging, isOver, isSorting, collapsed, setCollapsed } |
useSortableList(options) returns
| Field | Description |
|---|---|
| items | Current array |
| setItems | React state setter |
| move(from, to) | Reorder by index |
| moveItem(id, to) | Reorder by id |
| add(item, atIndex?) | Insert (returns the resolved id) |
| remove(id) | Delete |
| duplicate(id) | Copy below original (returns new id) |
| update(id, patch) | Partial patch |
| reset(next?) | Replace whole list |
| indexOf(id) | Find index by id |
Recipes
Minimal list
const [items, setItems] = useState([
{ id: "a", label: "Apple" },
{ id: "b", label: "Banana" },
{ id: "c", label: "Cherry" },
]);
<Sortable items={items} onChange={setItems}>
{items.map((item) => (
<Sortable.Item key={item.id} id={item.id}>
<div className="flex items-center gap-2 p-2 rounded-md border border-zinc-200 bg-white">
<Sortable.Handle />
<span>{item.label}</span>
</div>
</Sortable.Item>
))}
</Sortable>Grid
<Sortable items={items} onChange={setItems} orientation="grid">
{items.map((item) => (
<Sortable.Item key={item.id} id={item.id}>
<div className="aspect-square rounded-lg border border-zinc-200 bg-white">
{item.label}
</div>
</Sortable.Item>
))}
</Sortable>Disabled rows
<Sortable.Item id={item.id} disabled={item.locked}>
{/* handle becomes inactive */}
</Sortable.Item>External controls
const list = useSortableList({ initial });
<Sortable items={list.items} onChange={list.setItems}>{/* … */}</Sortable>
<button onClick={() => list.add({ title: "..." })}>+ add</button>
<button onClick={() => list.move(0, list.items.length - 1)}>send first to end</button>
<button onClick={() => list.reset()}>clear</button>Render-prop
<Sortable.Item id={item.id}>
{({ index, isDragging }) => (
<div className={isDragging ? "border-blue-400 bg-blue-50" : "border-zinc-200"}>
<Sortable.Handle />
<span>#{index + 1}</span>
<span>{item.label}</span>
</div>
)}
</Sortable.Item>Custom modifier
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
<Sortable items={items} onChange={setItems} modifiers={[restrictToWindowEdges]}>
{/* … */}
</Sortable>Compatibility
Works with any React-based framework: Next.js, Remix, Vite + React, Gatsby.
Components are marked "use client" (drag-and-drop requires the DOM). On the server they render a static layout; the drag interaction begins on client mount.
Development
npm install
npm run dev # tsup watch mode
npm run build # production build to dist/
npm run typecheck # tsc --noEmit
npm run storybook # Storybook dev (http://localhost:6006)
npm run build-storybook # static Storybook exportPublishing
Publishing is automated via GitHub Actions. When a GitHub Release is created, the package is published to npm.
- Update
versioninpackage.json - Commit and push
- Create a GitHub Release with a matching tag (e.g.
v1.0.0)
Author
Kenan Gündoğan — https://github.com/kenangundogan
Maintained under Eglador
License
MIT
