kyle.berry
All components

Component · Jul 9, 2026

Sortable list

Drag-to-reorder where the keyboard is a first-class citizen: grab, move, drop, and cancel, with every move announced.

Sortable list

Reading list · drag the handle, or focus it and press Space

  1. The Elements of Typographic StyleRobert Bringhurst1
  2. How to Make Sense of Any MessAbby Covert2
  3. Notes on the Synthesis of FormChristopher Alexander3
  4. Grid Systems in Graphic DesignJosef Müller-Brockmann4
  5. The Design of Everyday ThingsDon Norman5
component.tsx
'use client'

import { useId, useRef, useState, useSyncExternalStore } from 'react'

/**
 * Drag-to-reorder with a first-class keyboard path: grab with Space, move
 * with arrows, drop with Space, cancel with Escape. Every move is announced
 * to a live region, and neighbor shifts animate unless motion is reduced.
 */

const ITEM_H = 48
const GAP = 8
const STRIDE = ITEM_H + GAP

interface Item {
  id: string
  title: string
  author: string
}

const INITIAL_ITEMS: Item[] = [
  { id: 'elements', title: 'The Elements of Typographic Style', author: 'Robert Bringhurst' },
  { id: 'thinking', title: 'How to Make Sense of Any Mess', author: 'Abby Covert' },
  { id: 'form', title: 'Notes on the Synthesis of Form', author: 'Christopher Alexander' },
  { id: 'grid', title: 'Grid Systems in Graphic Design', author: 'Josef Müller-Brockmann' },
  { id: 'everyday', title: 'The Design of Everyday Things', author: 'Don Norman' },
]

const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'

function subscribeToMotionPreference(onStoreChange: () => void): () => void {
  const media = window.matchMedia(REDUCED_MOTION_QUERY)
  media.addEventListener('change', onStoreChange)
  return () => media.removeEventListener('change', onStoreChange)
}

function usePrefersReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribeToMotionPreference,
    () => window.matchMedia(REDUCED_MOTION_QUERY).matches,
    () => false,
  )
}

function moveItem(items: Item[], from: number, to: number): Item[] {
  const next = [...items]
  const [moved] = next.splice(from, 1)
  if (!moved) return items
  next.splice(to, 0, moved)
  return next
}

const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))

export default function SortableList() {
  const [items, setItems] = useState(INITIAL_ITEMS)
  const [dragIndex, setDragIndex] = useState<number | null>(null)
  const [dragDy, setDragDy] = useState(0)
  const [grabbedId, setGrabbedId] = useState<string | null>(null)
  const [announcement, setAnnouncement] = useState('')

  const startYRef = useRef(0)
  const dyRef = useRef(0)
  const orderBeforeGrab = useRef<Item[]>([])
  const reducedMotion = usePrefersReducedMotion()
  const instructionsId = useId()

  const count = items.length
  const projectedIndex =
    dragIndex !== null ? clamp(dragIndex + Math.round(dragDy / STRIDE), 0, count - 1) : null

  /* Pointer path ------------------------------------------------------- */

  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>, index: number) => {
    if (grabbedId !== null) return
    e.currentTarget.setPointerCapture(e.pointerId)
    startYRef.current = e.clientY
    dyRef.current = 0
    setDragDy(0)
    setDragIndex(index)
  }

  const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
    if (dragIndex === null) return
    dyRef.current = e.clientY - startYRef.current
    setDragDy(dyRef.current)
  }

  const onPointerEnd = () => {
    if (dragIndex === null) return
    const target = clamp(dragIndex + Math.round(dyRef.current / STRIDE), 0, count - 1)
    const moved = items[dragIndex]
    if (target !== dragIndex && moved) {
      setItems((current) => moveItem(current, dragIndex, target))
      setAnnouncement(`Moved ${moved.title} to position ${target + 1} of ${count}.`)
    }
    setDragIndex(null)
    setDragDy(0)
    dyRef.current = 0
  }

  /* Keyboard path ------------------------------------------------------ */

  const onHandleKeyDown = (e: React.KeyboardEvent, item: Item, index: number) => {
    const grabbed = grabbedId === item.id

    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault()
      if (!grabbed) {
        orderBeforeGrab.current = items
        setGrabbedId(item.id)
        setAnnouncement(
          `Grabbed ${item.title}, position ${index + 1} of ${count}. ` +
            'Arrow keys to move, Space to drop, Escape to cancel.',
        )
      } else {
        setGrabbedId(null)
        setAnnouncement(`Dropped ${item.title} at position ${index + 1} of ${count}.`)
      }
      return
    }

    if (!grabbed) return

    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
      e.preventDefault()
      const target = clamp(index + (e.key === 'ArrowUp' ? -1 : 1), 0, count - 1)
      if (target !== index) {
        setItems((current) => moveItem(current, index, target))
        setAnnouncement(`${item.title}, position ${target + 1} of ${count}.`)
      }
    } else if (e.key === 'Escape') {
      e.preventDefault()
      const original = orderBeforeGrab.current.findIndex((i) => i.id === item.id)
      setItems(orderBeforeGrab.current)
      setGrabbedId(null)
      setAnnouncement(
        `Reorder cancelled. ${item.title} returned to position ${original + 1} of ${count}.`,
      )
    }
  }

  /* Render ------------------------------------------------------------- */

  const shiftFor = (index: number): number => {
    if (dragIndex === null || projectedIndex === null || index === dragIndex) return 0
    if (dragIndex < projectedIndex && index > dragIndex && index <= projectedIndex) return -STRIDE
    if (dragIndex > projectedIndex && index < dragIndex && index >= projectedIndex) return STRIDE
    return 0
  }

  return (
    <div className="mx-auto w-md max-w-full">
      <p
        id={instructionsId}
        className="mb-3 font-mono text-[10px] tracking-[0.14em] text-(--color-fg-subtle) uppercase"
      >
        Reading list · drag the handle, or focus it and press Space
      </p>

      <ol className="relative" style={{ height: count * STRIDE - GAP }}>
        {items.map((item, index) => {
          const isDragged = dragIndex === index
          const isGrabbed = grabbedId === item.id
          const y = index * STRIDE + (isDragged ? dragDy : shiftFor(index))

          return (
            <li
              key={item.id}
              className={`absolute inset-x-0 flex items-center gap-3 rounded-(--radius-card) border bg-(--color-surface) px-3 ${
                isDragged || isGrabbed
                  ? 'z-10 border-(--color-border-strong)'
                  : 'border-(--color-border)'
              }`}
              style={{
                height: ITEM_H,
                transform: `translateY(${y}px)`,
                transition:
                  isDragged || reducedMotion ? 'none' : 'transform 160ms cubic-bezier(0.22,1,0.36,1)',
              }}
            >
              <button
                type="button"
                aria-label={`Reorder ${item.title}, position ${index + 1} of ${count}`}
                aria-describedby={instructionsId}
                aria-pressed={isGrabbed}
                onPointerDown={(e) => onPointerDown(e, index)}
                onPointerMove={onPointerMove}
                onPointerUp={onPointerEnd}
                onPointerCancel={onPointerEnd}
                onKeyDown={(e) => onHandleKeyDown(e, item, index)}
                className={`touch-none rounded px-1.5 py-1 font-mono text-sm select-none ${
                  isGrabbed
                    ? 'bg-(--color-accent-soft) text-(--color-fg)'
                    : 'cursor-grab text-(--color-fg-subtle) hover:text-(--color-fg)'
                }`}
              >

              </button>
              <span className="min-w-0">
                <span className="block truncate text-sm text-(--color-fg)">{item.title}</span>
                <span className="block font-mono text-[10px] text-(--color-fg-subtle)">
                  {item.author}
                </span>
              </span>
              <span className="ml-auto font-mono text-[10px] text-(--color-fg-subtle)">
                {index + 1}
              </span>
            </li>
          )
        })}
      </ol>

      <div aria-live="polite" className="sr-only">
        {announcement}
      </div>

      <p className="mt-3 h-4 font-mono text-[10px] text-(--color-fg-subtle)" aria-hidden="true">
        {grabbedId !== null ? '↑↓ move · space drop · esc cancel' : announcement}
      </p>
    </div>
  )
}

Almost every drag-and-drop demo on the internet has the same asterisk: mouse only. The drag part is genuinely fun to build, so that is where the effort goes, and the keyboard path gets a TODO. I wanted to build one where the keyboard path is not a fallback but a complete, pleasant interaction of its own.

The keyboard model follows the pattern screen reader users already know from apps that do this well: focus a row's handle, press Space to grab it, move it with the arrow keys, press Space again to drop, or Escape to put everything back the way it was. Escape matters more than it looks. A grab snapshots the current order, so cancelling is a true undo rather than "stop wherever you happen to be." The handle's aria-pressed state reflects grabbed-ness, and instructions are attached with aria-describedby so they are discoverable without being repeated on every row.

Announcements are the other half of the keyboard story. A visually hidden aria-live region narrates each state change: "Grabbed, position 2 of 5," "position 3 of 5," "Dropped." Without this, a non-visual user pressing arrow keys gets silence, and silence reads as broken. The same strings render visibly in the footer, which turned out to be a nice sighted-user feature for free.

The pointer path uses a projection approach instead of live array reordering: while you drag, the list order never changes. The dragged row follows the pointer, a projected index is computed from the drag distance, and neighboring rows slide out of the way with a 160ms transform. Only on release does the array actually reorder. This avoids the jitter you get when reordering state mid-drag, where the item under your pointer keeps changing identity. Rows are absolutely positioned on a fixed stride, so the neighbor shifts are just translateY by one row height, no FLIP measurement required.

Under prefers-reduced-motion the neighbor transitions turn off and rows snap to their new positions. The interaction survives intact; only the choreography goes.

Related

Live leaderboardmotion
Async comboboxaccessibility
Toast queueaccessibility