Sortable List
Drag-to-reorder list where only the grip handle lifts a row, the lifted row rises with a soft shadow and slight scale while its siblings spring out of the way, and a full keyboard path (Space to pick up, arrows to move, Space to drop, Escape to restore) announces every move to screen readers. Generic over any item with an id, controlled/uncontrolled, reduced-motion safe.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/sortable-list
import {
motion,
Reorder,
useDragControls,
useReducedMotion,
} from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type SortableItem = { id: string | number };
export type SortableItemState = {
/** Zero-based position in the list as currently shown. */
index: number;
/** Total number of items. */
count: number;
/** True while the item is lifted by pointer or keyboard. */
isDragging: boolean;
};
export interface SortableListProps<T extends SortableItem> {
/** Controlled order. */
items?: T[];
/** Initial order when uncontrolled. */
defaultItems?: T[];
/** Fires once per completed move (pointer release or keyboard drop). */
onChange?: (items: T[]) => void;
renderItem: (item: T, state: SortableItemState) => ReactNode;
/** Human-readable name used for the handle label and announcements. */
getLabel?: (item: T) => string;
className?: string;
itemClassName?: string;
}
/** Keyboard pick-up session: what to restore if the move is cancelled. */
type Grab<T extends SortableItem> = { id: T["id"]; original: T[] };
const INSTANT = { duration: 0 } as const;
function positionOf<T extends SortableItem>(list: T[], id: T["id"]) {
return list.findIndex((entry) => entry.id === id) + 1;
}
function moveItem<T>(list: T[], from: number, to: number) {
const next = list.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
function GripIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4"
fill="currentColor"
>
<circle cx="5.5" cy="3.5" r="1.25" />
<circle cx="10.5" cy="3.5" r="1.25" />
<circle cx="5.5" cy="8" r="1.25" />
<circle cx="10.5" cy="8" r="1.25" />
<circle cx="5.5" cy="12.5" r="1.25" />
<circle cx="10.5" cy="12.5" r="1.25" />
</svg>
);
}
function SortableRow<T extends SortableItem>({
item,
index,
count,
label,
lifted,
grabbed,
describedBy,
reduce,
className,
registerHandle,
onPointerLift,
onPointerDrop,
onHandleKeyDown,
onHandleBlur,
children,
}: {
item: T;
index: number;
count: number;
label: string;
lifted: boolean;
grabbed: boolean;
describedBy: string;
reduce: boolean;
className?: string;
registerHandle: (id: T["id"], el: HTMLButtonElement | null) => void;
onPointerLift: (item: T) => void;
onPointerDrop: (item: T) => void;
onHandleKeyDown: (event: KeyboardEvent<HTMLButtonElement>, item: T) => void;
onHandleBlur: (relatedTarget: EventTarget | null) => void;
children: ReactNode;
}) {
const controls = useDragControls();
return (
<Reorder.Item
as="li"
value={item}
dragListener={false}
dragControls={controls}
onDragStart={() => onPointerLift(item)}
onDragEnd={() => onPointerDrop(item)}
transition={reduce ? INSTANT : SPRING_LAYOUT}
animate={{ scale: lifted && !reduce ? 1.02 : 1 }}
className={cn(
"relative flex items-center gap-3 rounded-2xl border border-border bg-card px-3 py-2.5",
lifted ? "z-10" : "z-0",
className,
)}
>
{/* The lift shadow is a separate layer so only its opacity animates. */}
<motion.span
aria-hidden="true"
initial={false}
animate={{ opacity: lifted ? 1 : 0 }}
transition={reduce ? INSTANT : { duration: 0.15 }}
className="pointer-events-none absolute inset-0 -z-10 rounded-2xl shadow-[0_12px_32px_-12px_rgb(0_0_0/0.35)]"
/>
<button
type="button"
ref={(el) => registerHandle(item.id, el)}
aria-label={`Reorder ${label}, position ${index + 1} of ${count}`}
aria-describedby={describedBy}
aria-pressed={grabbed}
onPointerDown={(event) => {
event.preventDefault();
controls.start(event);
}}
onKeyDown={(event) => onHandleKeyDown(event, item)}
onBlur={(event) => onHandleBlur(event.relatedTarget)}
className={cn(
"grid h-8 w-8 shrink-0 cursor-grab touch-none place-items-center rounded-lg text-muted-foreground outline-none transition-colors",
"hover:bg-muted hover:text-foreground active:cursor-grabbing",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
grabbed && "bg-muted text-foreground",
TOUCH_GESTURE_CLASS,
)}
>
<GripIcon />
</button>
<div className="min-w-0 flex-1">{children}</div>
</Reorder.Item>
);
}
export function SortableList<T extends SortableItem>({
items,
defaultItems,
onChange,
renderItem,
getLabel = (item) => String(item.id),
className,
itemClassName,
}: SortableListProps<T>) {
const reduce = useReducedMotion() ?? false;
const instructionsId = useId();
const [internal, setInternal] = useState<T[]>(defaultItems ?? []);
const isControlled = items !== undefined;
const committed = isControlled ? items : internal;
// Working order while a move is in progress; committed on drop.
const [draft, setDraft] = useState<T[] | null>(null);
const [draggingId, setDraggingId] = useState<T["id"] | null>(null);
const [grab, setGrab] = useState<Grab<T> | null>(null);
const [announcement, setAnnouncement] = useState("");
const handles = useRef(new Map<T["id"], HTMLButtonElement>());
const view = draft ?? committed;
const commit = useCallback(
(next: T[]) => {
if (!isControlled) setInternal(next);
onChange?.(next);
},
[isControlled, onChange],
);
const registerHandle = useCallback(
(id: T["id"], el: HTMLButtonElement | null) => {
if (el) handles.current.set(id, el);
else handles.current.delete(id);
},
[],
);
// React moves list nodes when the order changes, which can drop focus from
// the grabbed handle — put it back after every keyboard move.
const grabbedId = grab?.id;
const grabbedIndex =
grabbedId === undefined
? -1
: view.findIndex((entry) => entry.id === grabbedId);
useEffect(() => {
if (grabbedId === undefined || grabbedIndex < 0) return;
const el = handles.current.get(grabbedId);
if (el && document.activeElement !== el) el.focus();
}, [grabbedId, grabbedIndex]);
const drop = (item: T, list: T[]) => {
setGrab(null);
setDraft(null);
setAnnouncement(
`${getLabel(item)} dropped at position ${positionOf(list, item.id)} of ${list.length}.`,
);
const changed = list.some((entry, i) => entry !== committed[i]);
if (changed) commit(list);
};
const onHandleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, item: T) => {
const label = getLabel(item);
const isToggle = event.key === " " || event.key === "Enter";
if (!grab) {
if (!isToggle) return;
event.preventDefault();
setGrab({ id: item.id, original: committed });
setDraft(committed);
setAnnouncement(
`Picked up ${label}, position ${positionOf(committed, item.id)} of ${committed.length}. Use the up and down arrow keys to move, Space to drop, Escape to cancel.`,
);
return;
}
if (grab.id !== item.id) return;
if (isToggle) {
event.preventDefault();
drop(item, view);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setGrab(null);
setDraft(null);
setAnnouncement(
`Move cancelled. ${label} returned to position ${positionOf(grab.original, item.id)} of ${grab.original.length}.`,
);
return;
}
const delta =
event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
if (!delta) return;
event.preventDefault();
const from = view.findIndex((entry) => entry.id === item.id);
const to = Math.max(0, Math.min(view.length - 1, from + delta));
if (to === from) return;
setDraft(moveItem(view, from, to));
setAnnouncement(`Moved ${label} to position ${to + 1} of ${view.length}.`);
};
const onHandleBlur = (relatedTarget: EventTarget | null) => {
// Focus leaving for another element (Tab, click elsewhere) drops the item
// where it is. A null target is React re-parenting the node mid-move.
if (!grab || !relatedTarget) return;
const item = view.find((entry) => entry.id === grab.id);
if (item) drop(item, view);
};
return (
<div className={cn("w-full", className)}>
<p id={instructionsId} className="sr-only">
Press Space or Enter to pick up an item, the arrow keys to move it,
Space or Enter to drop it, and Escape to cancel.
</p>
<Reorder.Group
as="ul"
axis="y"
values={view}
onReorder={setDraft}
className="flex flex-col gap-2"
>
{view.map((item, index) => (
<SortableRow
key={item.id}
item={item}
index={index}
count={view.length}
label={getLabel(item)}
lifted={draggingId === item.id || grab?.id === item.id}
grabbed={grab?.id === item.id}
describedBy={instructionsId}
reduce={reduce}
className={itemClassName}
registerHandle={registerHandle}
onPointerLift={(lifted) => {
setDraggingId(lifted.id);
setDraft((current) => current ?? committed);
}}
onPointerDrop={(dropped) => {
setDraggingId(null);
drop(dropped, draft ?? committed);
}}
onHandleKeyDown={onHandleKeyDown}
onHandleBlur={onHandleBlur}
>
{renderItem(item, {
index,
count: view.length,
isDragging: draggingId === item.id || grab?.id === item.id,
})}
</SortableRow>
))}
</Reorder.Group>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
</div>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
items?T[]Controlled order.
—defaultItems?T[]Initial order when uncontrolled.
—onChange?((items: T[]) => void)Fires once per completed move (pointer release or keyboard drop).
—renderItem(item: T, state: SortableItemState) => ReactNode—getLabel?((item: T) => string)Human-readable name used for the handle label and announcements.
(item) => String(item.id)className?string—itemClassName?string—Updated