The grip that makes drag-and-drop lists keyboard-accessible: Space lifts, arrows move with polite announcements, Space drops, Escape cancels — pointer drags wire straight through.
npx shadcn@latest add @paragon/drag-handle-button"use client";
import * as React from "react";
import { GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
export interface DragHandleButtonProps
extends Omit<React.ComponentProps<"button">, "aria-label"> {
/** What this handle reorders, e.g. "Notify on-call engineer". */
itemLabel?: string;
/**
* Keyboard reorder: fires with -1 (up) or +1 (down) while lifted.
* Return `false` when the move is blocked (already at an edge) and the
* handle announces that instead of a move.
*/
onMove?: (direction: -1 | 1) => boolean | void;
/** Controlled lifted state — e.g. mirror your pointer-drag state. */
lifted?: boolean;
onLiftedChange?: (lifted: boolean) => void;
/** Disables the lift scale; keyboard reordering still works. */
static?: boolean;
}
/**
* The grip that makes drag-and-drop lists keyboard-accessible. Pointer
* users grab it (wire `onPointerDown` to your drag library); keyboard
* users press Space to lift, arrows to move — each step announced — and
* Space again to drop. Escape or blur cancels. The lift is a controlled
* or uncontrolled state, so pointer drags can mirror into the same visual.
*/
export function DragHandleButton({
itemLabel = "item",
onMove,
lifted: liftedProp,
onLiftedChange,
static: isStatic = false,
className,
disabled,
onKeyDown,
onBlur,
...props
}: DragHandleButtonProps) {
const instructionsId = React.useId();
const [uncontrolledLifted, setUncontrolledLifted] = React.useState(false);
const isControlled = liftedProp !== undefined;
const lifted = isControlled ? liftedProp : uncontrolledLifted;
const [message, setMessage] = React.useState("");
const setLifted = (next: boolean) => {
if (!isControlled) setUncontrolledLifted(next);
onLiftedChange?.(next);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
const next = !lifted;
setLifted(next);
setMessage(
next
? `${itemLabel} lifted. Use arrow keys to move, space to drop.`
: `${itemLabel} dropped.`,
);
} else if (
lifted &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
event.preventDefault();
const direction = event.key === "ArrowUp" ? -1 : 1;
const result = onMove?.(direction);
setMessage(
result === false
? `${itemLabel} is already at the ${direction === -1 ? "top" : "bottom"}.`
: `${itemLabel} moved ${direction === -1 ? "up" : "down"}.`,
);
} else if (lifted && event.key === "Escape") {
event.preventDefault();
setLifted(false);
setMessage("Reordering cancelled.");
}
};
return (
<button
type="button"
data-slot="drag-handle-button"
data-lifted={lifted || undefined}
aria-label={`Reorder ${itemLabel}`}
aria-pressed={lifted}
aria-describedby={instructionsId}
aria-roledescription="sortable handle"
disabled={disabled}
onKeyDown={handleKeyDown}
onBlur={(event) => {
onBlur?.(event);
if (lifted) {
setLifted(false);
setMessage(`${itemLabel} dropped.`);
}
}}
className={cn(
"relative inline-flex size-7 shrink-0 touch-none items-center justify-center rounded-md text-muted-foreground outline-none",
"cursor-grab active:cursor-grabbing",
"transition-[color,background-color,box-shadow,scale] duration-150 ease-out",
"hover:bg-accent hover:text-foreground",
"focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
lifted && "bg-accent text-foreground ring-1 ring-ring/40",
lifted && !isStatic && "scale-[1.08]",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
className,
)}
{...props}
>
<GripVertical aria-hidden className="size-4" />
<span id={instructionsId} className="sr-only">
Press space to lift, arrow keys to move, space again to drop, escape
to cancel.
</span>
<span aria-live="polite" className="sr-only">
{message}
</span>
</button>
);
}