@mshafiqyajid/react-sortable
Headless hook and styled component for drag-and-drop lists. Native pointer events — no external DnD library. Keyboard reorder with live region announcements, spring-animated displaced items, ghost-follows-pointer drag, horizontal and vertical orientation, handle or full-item drag, reduced-motion safe.
Playground #
import { SortableStyled } from "@mshafiqyajid/react-sortable/styled";
import "@mshafiqyajid/react-sortable/styles.css";
<SortableStyled
items={items}
onReorder={setItems}
renderItem={renderItem}
/>Install #
npm install @mshafiqyajid/react-sortable Quick start #
import { useState } from "react";
import { SortableStyled } from "@mshafiqyajid/react-sortable/styled";
import "@mshafiqyajid/react-sortable/styles.css";
interface Task {
id: string;
title: string;
}
function App() {
const [items, setItems] = useState<Task[]>([
{ id: "1", title: "Design tokens" },
{ id: "2", title: "Write tests" },
{ id: "3", title: "Ship it" },
]);
return (
<SortableStyled
items={items}
onReorder={setItems}
renderItem={(item, { isDragging }) => (
<span style={{ opacity: isDragging ? 0.5 : 1 }}>
{(item as Task).title}
</span>
)}
/>
);
} Headless hook #
import { useSortable } from "@mshafiqyajid/react-sortable";
const { containerProps, getItemProps, getItemState, activeId, isDragging } =
useSortable({ items, onReorder: setItems });
const { ref, ...restContainerProps } = containerProps;
return (
<ul ref={ref} {...restContainerProps}>
{items.map((item) => {
const { isDragging, isOver, handleProps } = getItemState(item);
return (
<li
key={item.id}
{...getItemProps(item)}
style={{ opacity: isDragging ? 0.4 : 1 }}
>
<span {...handleProps} style={{ cursor: "grab" }}>⠿</span>
{item.label}
</li>
);
})}
</ul>
); Keyboard controls #
| Key | Action |
|---|---|
| Space | Pick up item at focused position |
| Enter | Drop item at current position |
| ArrowUp / ArrowDown | Move item (vertical list) |
| ArrowLeft / ArrowRight | Move item (horizontal list) |
| Escape | Cancel — restore original order |
A live region announces position changes to screen readers during keyboard reordering.
API — SortableStyled #
| Prop | Type | Default | Description |
|---|---|---|---|
| items | SortableItem[] | — | Array of items. Each must have a unique id (string or number). |
| onReorder | (items: SortableItem[]) => void | — | Called with the reordered array after every drop or keyboard move. |
| renderItem | (item, state) => ReactNode | — | Render each row. Receives isDragging, isOver, handleProps. |
| orientation | "vertical" | "horizontal" | "vertical" | List direction. Horizontal uses ArrowLeft/Right for keyboard navigation. |
| handle | boolean | true | Show drag handle grip icon. When false the whole item is the drag target. |
| disabled | boolean | false | Disable all drag and keyboard interaction. |
| animationDuration | number | 200 | CSS transition duration in milliseconds for displaced items. |
| className | string | — | Extra class on the container element. |
| style | CSSProperties | — | Inline styles on the container element. |
renderItem state #
| Field | Type | Description |
|---|---|---|
| isDragging | boolean | True when this item is the active drag or keyboard-picked item. |
| isOver | boolean | True when the pointer is over this item (drop target indicator). |
| handleProps | object | Spread onto a custom handle element. Provides pointer and aria attributes. |
useSortable hook #
import { useSortable } from "@mshafiqyajid/react-sortable";
const {
items, // current items array (same reference passed in)
containerProps, // spread on the container: role, aria-orientation, data-dragging, ref
getItemProps, // (item) => props to spread on each item element
getItemState, // (item) => { isDragging, isOver, handleProps }
activeId, // id of the dragged/picked item, or null
overId, // id of the item currently under the pointer, or null
isDragging, // boolean — true when a drag is in progress
liveRegionText, // current announcement for screen readers
} = useSortable({ items, onReorder, orientation, disabled, animationDuration }); CSS variables #
/* Paste into your stylesheet to customise */
:root {
--rsort-bg: #f4f4f5;
--rsort-item-bg: #ffffff;
--rsort-item-fg: #18181b;
--rsort-item-border: #e4e4e7;
--rsort-item-radius: 8px;
--rsort-item-padding: 10px 14px;
--rsort-handle-color: #a1a1aa;
--rsort-gap: 6px;
--rsort-font-size: 0.875rem;
--rsort-over-bg: #f0f0ff;
--rsort-over-border: #6366f1;
--rsort-active-opacity: 0.4;
--rsort-duration: 200ms;
--rsort-focus-ring: 0 0 0 2px #6366f1;
} ARIA #
Container: role="listbox", aria-orientation, data-dragging when active.
Items: role="option", aria-roledescription="sortable item", aria-grabbed.
Data attributes: data-active on the dragged item; data-over on the drop target; data-orientation on the container.
A visually-hidden live region (role="status", aria-live="assertive") announces position changes during keyboard reorder and cancellation.