Drag Handle Button
Buttons

Drag Handle Button

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.

Install

npx shadcn@latest add @paragon/drag-handle-button

drag-handle-button.tsx

"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>
  );
}