Sort a list, nest a tree, or move items between lists with a pointer or keyboard.

createReorder sorts lists and grids, createTreeReorder moves rows and subtrees, and createTransfer moves items between lists. They keep a draft during a move and commit it through your change on drop. Use bindDrag to track a pointer for a custom drag.

Import these helpers from @ui-sdk/core. Compose Behaviors shows how to connect a behavior to a primitive. For freely positioned items, see Dragging and Resizing.

Pointer Drags

Follow a Pointer

bindDrag follows the pointer that pressed an element. Call it from a pointer handler, and keep its cleanup so destroying the primitive also stops the drag:

let stop: (() => void) | undefined
context.onDestroy(() => stop?.())

return {
  pointerdown(event: PointerEvent, el: HTMLElement) {
    if (event.button !== 0) {
      return
    }
    stop?.()
    stop = bindDrag(el, event, {
      threshold: dragThreshold,
      cancelOnEscape: true,
      onMove: ({ x }) => resizeTo(x),
      onEnd: (_event, canceled) => (canceled ? restore() : commit()),
    })
  },
}

Here resizeTo, restore, and commit are your splitter's own methods. bindDrag only tracks the pointer; you decide what each position means and add the keyboard controls.

import * as React from "react"
import {
  bindDrag,
  createChange,
  definePrimitive,
  dragThreshold,
  type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type SplitState = { size: number; draft: number | null }

type SplitOptions = {
  defaultSize?: number
  onSizeChange?: (
    size: number,
    details: ChangeDetails<"drag" | "keyboard">
  ) => void
}

const clamp = (size: number) => Math.round(Math.min(80, Math.max(20, size)))

const split = definePrimitive({
  name: "split",
  options: { defaultSize: "number", onSizeChange: "callback" },
  state: (options: SplitOptions): SplitState => ({
    size: options.defaultSize ?? 50,
    draft: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    let stop: (() => void) | undefined
    context.onDestroy(() => stop?.())
    return {
      pointerdown(event: PointerEvent, el: HTMLElement) {
        const root = el.parentElement
        if (event.button !== 0 || !root) {
          return
        }
        stop?.()
        stop = bindDrag(el, event, {
          threshold: dragThreshold,
          cancelOnEscape: true,
          onMove: ({ x }) => {
            const box = root.getBoundingClientRect()
            store.set({ draft: clamp(((x - box.left) / box.width) * 100) })
          },
          onEnd: (end, canceled) => {
            const { draft } = store.get()
            stop = undefined
            store.set({ draft: null })
            if (!canceled && draft !== null) {
              change("size", draft, { reason: "drag", event: end })
            }
          },
        })
      },
      keydown(event: KeyboardEvent) {
        const by = { ArrowLeft: -5, ArrowRight: 5 }[event.key]
        if (!by) {
          return
        }
        event.preventDefault()
        change("size", clamp(store.get().size + by), {
          reason: "keyboard",
          event,
        })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { size: number }>({
      state: (state) => ({ size: state.draft ?? state.size }),
      attrs: (state) => ({
        style: `--split:${state.draft ?? state.size}%`,
        "data-dragging": state.draft !== null ? "" : undefined,
      }),
    }),
    divider: slot({
      attrs: (state) => ({
        role: "separator",
        tabindex: "0",
        "aria-orientation": "vertical",
        "aria-valuenow": String(state.draft ?? state.size),
        "aria-valuemin": "20",
        "aria-valuemax": "80",
        style: "touch-action:none",
      }),
      on: {
        pointerdown: (event, { el, api }) => api.pointerdown(event, el!),
        keydown: (event, { api }) => api.keydown(event),
      },
    }),
  }),
})

const parts = createParts(split, { root: "div", divider: "div" })

const Split = parts.Root

const SplitDivider = parts.Divider

function Example() {
  return (
    <Split>
      <div data-slot="bind-pane">Files</div>
      <SplitDivider aria-label="Resize panes" />
      <div data-slot="bind-pane">Editor</div>
    </Split>
  )
}

export { Example }
Drag the divider, or focus it and use Left and Right. Press Escape during a drag to restore the width.

onMove runs at most once a frame. onEnd receives a cancellation flag if the browser takes over the pointer or Escape cancels it. The returned cleanup stops tracking without calling onEnd.

dragThreshold waits for a few pixels of movement before onMove starts. A press can still end below that threshold, so don't assume every onEnd follows a move. Give a draggable surface touch-action: none when dragging should take precedence over scrolling.

Collapse Past a Threshold

A sidebar resizes between its minimum and maximum widths, and can collapse, but never rests in between. Below the minimum, let scrubTo decide: give it two snap points, collapsed and the minimum, each reaching halfway, and ease toward them:

import { scrubTo } from "@ui-sdk/core/number"

const collapse = { min: 0, max: minWidth, step: 1, snap: { points: [0, minWidth], threshold: minWidth / 2 } }

const widthAt = (pointer: number) =>
  pointer >= minWidth ? { value: Math.min(maxWidth, pointer), point: undefined } : scrubTo(pointer, collapse, { ease: true })

onMove: ({ x }) => {
  const { value, point } = widthAt(x - root.getBoundingClientRect().left)
  store.set({ draft: value, landing: point ?? null })
},
onEnd: () => commit(store.get().landing ?? store.get().draft),

While the pointer moves below the minimum, the sidebar follows it, picking up speed toward whichever point is closer, so it closes in on collapsing without a sudden jump. point says where it will land, so the demo can say so before release; dragging the other way opens it the same way.

import * as React from "react"
import {
  bindDrag,
  createChange,
  definePrimitive,
  dragThreshold,
  type ChangeDetails,
} from "@ui-sdk/core"
import { scrubTo } from "@ui-sdk/core/number"
import { createParts } from "@ui-sdk/react/primitive"

type SidebarState = {
  width: number
  opened: number
  draft: number | null
  landing: number | null
}

type SidebarOptions = {
  defaultWidth?: number
  onWidthChange?: (
    width: number,
    details: ChangeDetails<"drag" | "keyboard">
  ) => void
}

const minWidth = 160
const maxWidth = 280

const collapse = {
  min: 0,
  max: minWidth,
  step: 1,
  snap: { points: [0, minWidth], threshold: minWidth / 2 },
}

const widthAt = (pointer: number) =>
  pointer >= minWidth
    ? { value: Math.min(maxWidth, Math.round(pointer)), point: undefined }
    : scrubTo(pointer, collapse, { ease: true })

const sidebar = definePrimitive({
  name: "sidebar",
  options: { defaultWidth: "number", onWidthChange: "callback" },
  state: (options: SidebarOptions): SidebarState => ({
    width: options.defaultWidth ?? 200,
    opened: options.defaultWidth || 200,
    draft: null,
    landing: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const resize = (
      width: number,
      reason: "drag" | "keyboard",
      event: Event
    ) => {
      if (width) {
        store.set({ opened: width })
      }
      change("width", width, { reason, event })
    }
    let stop: (() => void) | undefined
    context.onDestroy(() => stop?.())
    return {
      pointerdown(event: PointerEvent, el: HTMLElement) {
        const root = el.parentElement
        if (event.button !== 0 || !root) {
          return
        }
        stop?.()
        stop = bindDrag(el, event, {
          threshold: dragThreshold,
          cancelOnEscape: true,
          onMove: ({ x }) => {
            const { value, point } = widthAt(
              x - root.getBoundingClientRect().left
            )
            store.set({ draft: value, landing: point ?? null })
          },
          onEnd: (end, canceled) => {
            const { draft, landing } = store.get()
            stop = undefined
            store.set({ draft: null, landing: null })
            if (!canceled && draft !== null) {
              resize(landing ?? draft, "drag", end)
            }
          },
        })
      },
      keydown(event: KeyboardEvent) {
        const { width, opened } = store.get()
        const next =
          event.key === "Enter"
            ? width
              ? 0
              : opened
            : event.key === "ArrowLeft"
              ? width - 16 < minWidth
                ? 0
                : width - 16
              : event.key === "ArrowRight"
                ? width
                  ? Math.min(maxWidth, width + 16)
                  : minWidth
                : undefined
        if (next === undefined) {
          return
        }
        event.preventDefault()
        resize(next, "keyboard", event)
      },
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: (state) => {
        const width = state.draft ?? state.width
        return {
          style: `--sidebar:${width}px`,
          "data-collapsed": width === 0 ? "" : undefined,
          "data-dragging": state.draft !== null ? "" : undefined,
          "data-landing":
            state.landing === null
              ? undefined
              : state.landing
                ? "open"
                : "collapsed",
        }
      },
    }),
    divider: slot({
      attrs: (state) => {
        const width = state.draft ?? state.width
        return {
          role: "separator",
          tabindex: "0",
          "aria-orientation": "vertical",
          "aria-valuenow": String(Math.round(width)),
          "aria-valuemin": "0",
          "aria-valuemax": String(maxWidth),
          "aria-valuetext": width ? `${Math.round(width)} pixels` : "Collapsed",
          style: "touch-action:none",
        }
      },
      on: {
        pointerdown: (event, { el, api }) => api.pointerdown(event, el!),
        keydown: (event, { api }) => api.keydown(event),
      },
    }),
  }),
})

const parts = createParts(sidebar, { root: "div", divider: "div" })

const Sidebar = parts.Root

const SidebarDivider = parts.Divider

function Example() {
  return (
    <Sidebar defaultWidth={160}>
      <nav aria-label="Files" data-slot="sidebar-nav">
        <ul>
          {["README.md", "package.json", "src", "tests"].map((file) => (
            <li key={file}>{file}</li>
          ))}
        </ul>
      </nav>
      <SidebarDivider aria-label="Resize sidebar" />
      <div data-slot="sidebar-hint">
        <span data-show="open">Drag the divider left to collapse</span>
        <span data-show="collapsed">Drag right a bit to open</span>
        <span data-show="landing-collapsed">Release to collapse</span>
        <span data-show="landing-open">Release to open at {minWidth}px</span>
      </div>
    </Sidebar>
  )
}

export { Example }
Drag the divider below the sidebar's minimum: it eases toward collapsing or back to the minimum, and says which it will do when you let go. Focus the divider for Left, Right and Enter.

Dragging Without Limits

For a scrubber, pointer lock lets the user keep dragging past the edge of the screen. Read the distance moved instead of the pointer's position:

bindDrag(el, event, {
  threshold: dragThreshold,
  lock: true,
  onStart: () => !context.options().locked,
  onMove: ({ movementX, event }) => {
    change("value", context.store.get().value + movementX / 2, {
      reason: "drag",
      event,
    })
  },
  onEnd: () => {},
})

onStart runs when the movement reaches the threshold. Returning false stops tracking without calling onEnd. If the browser can't lock the pointer, tracking continues with pointer capture.

import * as React from "react"
import {
  bindDrag,
  createChange,
  definePrimitive,
  dragThreshold,
  type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { Checkbox, CheckboxIndicator } from "@ui-sdk/react/checkbox"
import { CheckIcon } from "lucide-react"

type ScrubState = { value: number }

type ScrubOptions = {
  locked?: boolean
  onValueChange?: (
    value: number,
    details: ChangeDetails<"drag" | "keyboard">
  ) => void
}

const clamp = (value: number) => Math.round(Math.min(100, Math.max(0, value)))

const scrub = definePrimitive({
  name: "scrub",
  options: { locked: "boolean", onValueChange: "callback" },
  state: (_options: ScrubOptions): ScrubState => ({ value: 50 }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    let stop: (() => void) | undefined
    context.onDestroy(() => stop?.())
    return {
      pointerdown(event: PointerEvent, el: HTMLElement) {
        stop?.()
        stop = bindDrag(el, event, {
          threshold: dragThreshold,
          lock: true,
          onStart: () => !context.options().locked,
          onMove: ({ movementX, event }) =>
            change("value", clamp(store.get().value + movementX / 2), {
              reason: "drag",
              event,
            }),
          onEnd: () => (stop = undefined),
        })
      },
      keydown(event: KeyboardEvent) {
        const by = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[
          event.key
        ]
        if (!by) {
          return
        }
        event.preventDefault()
        change("value", clamp(store.get().value + by), {
          reason: "keyboard",
          event,
        })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, ScrubState>({
      state: (state) => ({ value: state.value }),
      attrs: (state) => ({
        role: "slider",
        tabindex: "0",
        "aria-valuemin": "0",
        "aria-valuemax": "100",
        "aria-valuenow": String(state.value),
        style: "touch-action:none;user-select:none;cursor:ew-resize",
      }),
      on: {
        pointerdown: (event, { el, api }) => api.pointerdown(event, el!),
        keydown: (event, { api }) => api.keydown(event),
      },
    }),
  }),
})

const parts = createParts(scrub, { root: "div" })

const Scrub = parts.Root

function Example() {
  const [locked, setLocked] = React.useState(false)
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <label data-slot="label">
          <Checkbox
            checked={locked}
            onCheckedChange={(next) => setLocked(next === true)}
          >
            <CheckboxIndicator>
              <CheckIcon aria-hidden="true" />
            </CheckboxIndicator>
          </Checkbox>
          Locked
        </label>
      </div>
      <Scrub aria-label="Opacity" locked={locked}>
        {({ value }) => (
          <>
            <span data-slot="hint">Opacity</span>
            <span>{value}%</span>
          </>
        )}
      </Scrub>
    </div>
  )
}

export { Example }
Drag the Opacity label left or right. When pointer lock is available, the cursor hides so you can keep going. Check Locked to prevent a drag.

The sample passed to onMove is reused. Read x, y, movementX, movementY, locked, and event during the callback instead of saving the object for later.

Reordering

Sort a List

Keep the committed order in a string[] and start a separate ReorderDraft | null field at null. In api, connect them to createReorder:

const change = createChange(context)
const reorder = createReorder(context, {
  slot: "item",
  key: "value",
  draft: "moving",
  set: (value, details) => change("value", value, details),
})

Return reorder from api. Render items from reorder.order(state), which reads the draft during a move and the committed value otherwise. Keep each item's key stable when the order changes.

The item slot runs the reorder handler before its existing roving focus handler. That lets arrows move focus until someone picks up an item:

on: {
  keydown: (event, { el, api, key }) =>
    api.reorder.keydown(event, key) || api.roving.keydown(event, el!),
  pointerdown: (event, { api, key }) => api.reorder.pointerdown(event, key),
},
bind: (el, part) => {
  const cleanups = [part.api.roving.bind(el, part), part.api.reorder.bind(el, part)]
  return () => cleanups.forEach((cleanup) => cleanup())
},
import * as React from "react"
import {
  createChange,
  createReorder,
  createRovingFocus,
  definePrimitive,
  type ChangeDetails,
  type ReorderDraft,
  type ReorderReason,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type RankingState = {
  value: string[]
  focused: string | null
  moving: ReorderDraft | null
}

type RankingOptions = {
  defaultValue: string[]
  onValueChange?: (
    value: string[],
    details: ChangeDetails<ReorderReason>
  ) => void
}

const ranking = definePrimitive({
  name: "ranking",
  options: { defaultValue: "list", onValueChange: "callback" },
  state: (options: RankingOptions): RankingState => ({
    value: options.defaultValue,
    focused: null,
    moving: null,
  }),
  api: (context) => {
    const change = createChange(context)
    return {
      roving: createRovingFocus<RankingState, { value: string }>(context, {
        slot: "item",
        key: "focused",
      }),
      reorder: createReorder<RankingState>(context, {
        slot: "item",
        key: "value",
        draft: "moving",
        set: (value, details) => change("value", value, details),
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { order: string[] }>({
      state: (state, { api }) => ({ order: api.reorder.order(state) }),
      attrs: () => ({
        role: "listbox",
        "aria-roledescription": "sortable list",
      }),
    }),
    item: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        "aria-roledescription": "sortable item",
        "aria-selected": String(state.moving?.key === part.key),
        tabindex: part.api.roving.tabIndex(state, part),
        "data-grabbed": state.moving?.key === part.key ? "" : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          api.reorder.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) =>
          api.reorder.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.reorder.bind(el, part),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
  }),
})

const parts = createParts(ranking, { root: "div", item: "div" })

const Ranking = parts.Root

const RankingItem = parts.Item

function Example() {
  return (
    <Ranking
      aria-label="Favorite seasons"
      defaultValue={["Spring", "Summer", "Autumn", "Winter"]}
    >
      {({ order }) =>
        order.map((season, index) => (
          <RankingItem key={season} value={season}>
            <span data-slot="hint">{index + 1}</span>
            {season}
          </RankingItem>
        ))
      }
    </Ranking>
  )
}

export { Example }
Drag a season to rank it. With the keyboard, focus one, press Space or Enter, move it with the arrows, then press Space or Enter to drop it. Escape cancels.

Home and End move a picked-up item to either end. Tab cancels the move and continues through the page. Changes report drag or move-key, and screen readers hear the pickup, moves, and drop.

This demo uses a listbox of options, aria-roledescription for the sortable list and items, and aria-selected for the picked-up item. Give each item an aria-describedby pointing to keyboard instructions. For items with nested controls, use a list with a separate drag handle button and put the handlers on that handle.

While dragging, core writes --drag-x and --drag-y onto the bound item. Use them to keep it under the pointer, with a data-grabbed attribute derived from state.moving?.key === key:

[data-slot="sortable-list-item"] {
  touch-action: none;
  user-select: none;
}
[data-slot="sortable-list-item"][data-grabbed] {
  transform: translate(var(--drag-x, 0), var(--drag-y, 0));
  position: relative;
  z-index: 1;
}

Grids

The default orientation is vertical. Add orientation: () => "grid" for items that wrap into rows:

orientation: () => "grid",
import * as React from "react"
import {
  createChange,
  createReorder,
  createRovingFocus,
  definePrimitive,
  spatialMove,
  type ReorderDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type AlbumState = {
  value: string[]
  focused: string | null
  moving: ReorderDraft | null
}

const album = definePrimitive({
  name: "album",
  state: (): AlbumState => ({
    value: [
      "Dawn",
      "Dune",
      "Fern",
      "Fjord",
      "Glacier",
      "Harbor",
      "Mesa",
      "Reef",
    ],
    focused: null,
    moving: null,
  }),
  api: (context) => {
    const change = createChange(context)
    return {
      roving: createRovingFocus<AlbumState, { value: string }>(context, {
        slot: "photo",
        key: "focused",
        move: spatialMove(),
      }),
      reorder: createReorder<AlbumState>(context, {
        slot: "photo",
        key: "value",
        draft: "moving",
        set: (value, details) => change("value", value, details),
        orientation: () => "grid",
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { order: string[] }>({
      state: (state, { api }) => ({ order: api.reorder.order(state) }),
      attrs: () => ({
        role: "listbox",
        "aria-roledescription": "sortable grid",
      }),
    }),
    photo: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        "aria-roledescription": "sortable item",
        "aria-selected": String(state.moving?.key === part.key),
        tabindex: part.api.roving.tabIndex(state, part),
        "data-grabbed": state.moving?.key === part.key ? "" : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          api.reorder.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) =>
          api.reorder.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.reorder.bind(el, part),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
  }),
})

const parts = createParts(album, { root: "div", photo: "div" })

const Album = parts.Root

const AlbumPhoto = parts.Photo

function Example() {
  return (
    <Album aria-label="Album">
      {({ order }) =>
        order.map((photo) => (
          <AlbumPhoto key={photo} value={photo}>
            {photo}
          </AlbumPhoto>
        ))
      }
    </Album>
  )
}

export { Example }
Drag a photo to a new position, or pick it up with Space. Left and Right move one place; Up and Down move between rows. Space drops it.

For a single horizontal row, use "horizontal", give its roving focus the same orientation, and set aria-orientation="horizontal" on the listbox.

Several at Once

A group callback lets a selection move together. Add it to the reorder options:

group: (key) => {
  const { selected } = context.store.get()
  return selected.includes(key) ? selected : [key]
},

The group is captured when the drag starts, keeps the list's order, and leaves disabled items behind. reorder.moving(state, key) identifies every item in the group; reorder.dragging(state, key) also requires a pointer drag.

import * as React from "react"
import {
  createChange,
  createReorder,
  createRovingFocus,
  definePrimitive,
  type ReorderDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type BacklogState = {
  value: string[]
  selected: string[]
  focused: string | null
  moving: ReorderDraft | null
}

const backlog = definePrimitive({
  name: "backlog",
  state: (): BacklogState => ({
    value: [
      "Login page",
      "Search",
      "Dark mode",
      "Exports",
      "Billing",
      "Onboarding",
    ],
    selected: [],
    focused: null,
    moving: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    return {
      roving: createRovingFocus<BacklogState, { value: string }>(context, {
        slot: "task",
        key: "focused",
      }),
      reorder: createReorder<BacklogState>(context, {
        slot: "task",
        key: "value",
        draft: "moving",
        set: (value, details) => change("value", value, details),
        group: (key) => {
          const { selected } = store.get()
          return selected.includes(key) ? selected : [key]
        },
      }),
      tick(key: string) {
        const { selected } = store.get()
        store.set({
          selected: selected.includes(key)
            ? selected.filter((other) => other !== key)
            : [...selected, key],
        })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { order: string[] }>({
      state: (state, { api }) => ({ order: api.reorder.order(state) }),
      attrs: () => ({ role: "list" }),
    }),
    task: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "listitem",
        tabindex: part.api.roving.tabIndex(state, part),
        "data-moving": part.api.reorder.moving(state, part.key)
          ? ""
          : undefined,
        "data-dragging": part.api.reorder.dragging(state, part.key)
          ? ""
          : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          api.reorder.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) =>
          api.reorder.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.reorder.bind(el, part),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
    tick: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        type: "button",
        role: "checkbox",
        "aria-checked": String(state.selected.includes(part.key)),
        "aria-label": `Select ${part.key}`,
        tabindex: "-1",
      }),
      on: {
        pointerdown: (event) => event.preventDefault(),
        click: (_event, { api, key }) => api.tick(key),
      },
    }),
  }),
})

const parts = createParts(backlog, { root: "div", task: "div", tick: "button" })

const Backlog = parts.Root

const BacklogTask = parts.Task

const BacklogTick = parts.Tick

function Example() {
  return (
    <Backlog aria-label="Backlog">
      {({ order }) =>
        order.map((task) => (
          <BacklogTask key={task} value={task}>
            <BacklogTick value={task} />
            {task}
          </BacklogTask>
        ))
      }
    </Backlog>
  )
}

export { Example }
Check a few tasks, then drag one of the checked tasks. You can also focus a checked task and use Space and the arrows to move the group.

A pointer event already handled with preventDefault() doesn't start a reorder. That lets a checkbox handle its press without also picking up the item.

A Copy While Dragging

For a scrolling list, a translated item may be clipped. Add preview: true to drag a copy above the page:

preview: true,
createAutoScroll: { edge: 40, speed: 12 },
import * as React from "react"
import {
  createChange,
  createReorder,
  createRovingFocus,
  definePrimitive,
  type ReorderDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type QueueState = {
  value: string[]
  focused: string | null
  moving: ReorderDraft | null
}

const songs = Array.from(
  { length: 16 },
  (_, index) => `Track ${String(index + 1).padStart(2, "0")}`
)

const queue = definePrimitive({
  name: "queue",
  state: (): QueueState => ({ value: songs, focused: null, moving: null }),
  api: (context) => {
    const change = createChange(context)
    return {
      roving: createRovingFocus<QueueState, { value: string }>(context, {
        slot: "song",
        key: "focused",
      }),
      reorder: createReorder<QueueState>(context, {
        slot: "song",
        key: "value",
        draft: "moving",
        set: (value, details) => change("value", value, details),
        preview: true,
        createAutoScroll: { edge: 40, speed: 12 },
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { order: string[] }>({
      state: (state, { api }) => ({ order: api.reorder.order(state) }),
      attrs: () => ({
        role: "listbox",
        "aria-roledescription": "sortable list",
      }),
    }),
    song: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        "aria-roledescription": "sortable item",
        "aria-selected": String(state.moving?.key === part.key),
        tabindex: part.api.roving.tabIndex(state, part),
        "data-dragging": part.api.reorder.dragging(state, part.key)
          ? ""
          : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          api.reorder.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) =>
          api.reorder.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.reorder.bind(el, part),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
  }),
})

const parts = createParts(queue, { root: "div", song: "div" })

const Queue = parts.Root

const QueueSong = parts.Song

function Example() {
  return (
    <Queue aria-label="Queue">
      {({ order }) =>
        order.map((song) => (
          <QueueSong key={song} value={song}>
            {song}
          </QueueSong>
        ))
      }
    </Queue>
  )
}

export { Example }
Drag a track and hold it near the playlist's top or bottom edge to scroll. Its placeholder shows where it will drop.

The original stays in the proposed order. With a group, the copy also gets data-drag-count and --drag-count for a count badge. Auto-scrolling is enabled with the preview; use createAutoScroll: false to turn it off.

Moving from Code

Use move for an immediate change, such as a menu's Move to Top action:

reorder.move("Serve", 0)

For controls that work one step at a time, use grab(key), moveTo(index), and drop() or cancel(). moveTo counts positions among the items left after removing the moving group.

import * as React from "react"
import {
  createChange,
  createReorder,
  createRovingFocus,
  definePrimitive,
  type ReorderDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"

type RecipeState = {
  value: string[]
  focused: string | null
  moving: ReorderDraft | null
}

const recipe = definePrimitive({
  name: "recipe",
  state: (): RecipeState => ({
    value: ["Boil water", "Add pasta", "Make the sauce", "Drain", "Serve"],
    focused: "Boil water",
    moving: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const reorder = createReorder<RecipeState>(context, {
      slot: "step",
      key: "value",
      draft: "moving",
      set: (value, details) => change("value", value, details),
    })
    return {
      roving: createRovingFocus<RecipeState, { value: string }>(context, {
        slot: "step",
        key: "focused",
      }),
      reorder,
      act(action: string) {
        const { focused, moving } = store.get()
        if (action === "grab" && focused) {
          reorder.grab(focused)
        } else if (moving && (action === "up" || action === "down")) {
          const index = moving.order.indexOf(moving.key)
          reorder.moveTo(index + (action === "up" ? -1 : 1))
        } else if (action === "drop") {
          reorder.drop()
        } else if (action === "cancel") {
          reorder.cancel()
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { order: string[] }>({
      state: (state, { api }) => ({ order: api.reorder.order(state) }),
    }),
    list: slot({
      attrs: () => ({
        role: "listbox",
        "aria-roledescription": "sortable list",
      }),
    }),
    step: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        "aria-roledescription": "sortable item",
        "aria-selected": String(state.moving?.key === part.key),
        tabindex: part.api.roving.tabIndex(state, part),
        "data-grabbed": state.moving?.key === part.key ? "" : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          api.reorder.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) =>
          api.reorder.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.reorder.bind(el, part),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
    toTop: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (_state, part) => ({
        type: "button",
        tabindex: "-1",
        "aria-label": `Move ${part.key} to top`,
      }),
      on: { pointerdown: (event) => event.preventDefault() },
      press: ({ api, key }) => api.reorder.move(key, 0),
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        type: "button",
        disabled: (part.key === "grab") === !!state.moving ? "" : undefined,
      }),
      on: { pointerdown: (event) => event.preventDefault() },
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(recipe, {
  root: "div",
  list: "div",
  step: "div",
  toTop: "button",
  action: "button",
})

const Recipe = parts.Root

const RecipeList = parts.List

const RecipeStep = parts.Step

const RecipeToTop = parts.ToTop

const RecipeAction = parts.Action

function Example() {
  return (
    <Recipe>
      {({ order }) => (
        <>
          <div data-slot="controls">
            <RecipeAction data-size="sm" value="grab">
              Pick Up
            </RecipeAction>
            <RecipeAction data-size="sm" value="up" aria-label="Move up">
              <ArrowUpIcon aria-hidden="true" />
            </RecipeAction>
            <RecipeAction data-size="sm" value="down" aria-label="Move down">
              <ArrowDownIcon aria-hidden="true" />
            </RecipeAction>
            <RecipeAction data-size="sm" value="drop">
              Drop
            </RecipeAction>
            <RecipeAction data-size="sm" value="cancel">
              Cancel
            </RecipeAction>
          </div>
          <RecipeList aria-label="Recipe">
            {order.map((step) => (
              <RecipeStep key={step} value={step}>
                {step}
                <RecipeToTop value={step}>
                  <ArrowUpIcon aria-hidden="true" />
                  Top
                </RecipeToTop>
              </RecipeStep>
            ))}
          </RecipeList>
        </>
      )}
    </Recipe>
  )
}

export { Example }
Use Top to move a recipe step immediately. The toolbar picks up the focused step, moves it, and either drops it or cancels.

Immediate moves report imperative; pickup and drop controls follow the same draft as the keyboard.

What's Read Out

Use label when an item's visible text contains more than its name. A custom message can replace just one announcement and keep the defaults for the rest:

disabled: (item) => item.key === context.options().pinned,
label: (key) => titles[key],
message: (message) =>
  message.type === "move" ? `${message.label}, track ${message.position}.` : reorderMessage(message),
import * as React from "react"
import {
  createChange,
  createReorder,
  createRovingFocus,
  definePrimitive,
  reorderMessage,
  type ReorderDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type TracksState = {
  value: string[]
  focused: string | null
  moving: ReorderDraft | null
}

type TracksOptions = {
  pinned: string
}

const titles: Record<string, string> = {
  intro: "Intro",
  tides: "Tides",
  ember: "Ember",
  static: "Static",
}

const tracks = definePrimitive({
  name: "tracks",
  options: { pinned: "string" },
  state: (_options: TracksOptions): TracksState => ({
    value: ["intro", "tides", "ember", "static"],
    focused: null,
    moving: null,
  }),
  api: (context) => {
    const change = createChange(context)
    return {
      roving: createRovingFocus<TracksState, { value: string }>(context, {
        slot: "track",
        key: "focused",
      }),
      reorder: createReorder<TracksState, { value: string }>(context, {
        slot: "track",
        key: "value",
        draft: "moving",
        set: (value, details) => change("value", value, details),
        disabled: (item) => item.key === context.options().pinned,
        label: (key) => titles[key],
        message: (message) =>
          message.type === "move"
            ? `${message.label}, track ${message.position}.`
            : reorderMessage(message),
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { order: string[] }>({
      state: (state, { api }) => ({ order: api.reorder.order(state) }),
      attrs: () => ({
        role: "listbox",
        "aria-roledescription": "sortable list",
      }),
    }),
    track: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        "aria-roledescription": "sortable item",
        "aria-selected": String(state.moving?.key === part.key),
        tabindex: part.api.roving.tabIndex(state, part),
        "data-grabbed": state.moving?.key === part.key ? "" : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          api.reorder.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) =>
          api.reorder.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.reorder.bind(el, part),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
  }),
})

const parts = createParts(tracks, { root: "div", track: "div" })

const Tracks = parts.Root

const TracksTrack = parts.Track

const lengths: Record<string, string> = {
  intro: "0:48",
  tides: "3:12",
  ember: "4:05",
  static: "2:37",
}

function Example() {
  return (
    <Tracks pinned="intro" aria-label="Tracks">
      {({ order }) =>
        order.map((track) => (
          <TracksTrack key={track} value={track}>
            {titles[track]}
            <span data-slot="hint">
              {track === "intro" ? "Pinned" : lengths[track]}
            </span>
          </TracksTrack>
        ))
      }
    </Tracks>
  )
}

export { Example }
Pick up an unpinned track with Space and move it with the arrows. A screen reader announces track numbers. The pinned track can't be picked up, but other tracks can move past it.

disabled prevents pickup; it doesn't reserve an item's position. Announcements are assertive, and several changes in one turn may be announced together as the latest message.

Reordering a Tree

Sort and Nest

A tree uses a flat array of { key, depth } rows in reading order. Moving a parent moves its whole subtree. Start moving at null with the type TreeReorderDraft | null:

const tree = createTreeReorder(context, {
  key: "rows",
  draft: "moving",
  set: (value, details) => change("rows", value, details),
  indent: () => 20,
  preview: true,
})

Render tree.rows(state), indent by depth, and bind each rendered row with tree.bind(el, key). Keep the roving focus binding too. As with a sortable list, call tree.keydown(event, key) before the focus handler and tree.pointerdown(event, key) on a press.

import * as React from "react"
import {
  createChange,
  createRovingFocus,
  createTreeReorder,
  css,
  definePrimitive,
  type ChangeDetails,
  type TreeReorderDraft,
  type TreeReorderReason,
  type TreeReorderRow,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type PagesState = {
  rows: TreeReorderRow[]
  focused: string | null
  moving: TreeReorderDraft | null
}

type PagesOptions = {
  defaultRows: TreeReorderRow[]
  onRowsChange?: (
    rows: TreeReorderRow[],
    details: ChangeDetails<TreeReorderReason>
  ) => void
}

const pages = definePrimitive({
  name: "pages",
  options: { defaultRows: "value", onRowsChange: "callback" },
  state: (options: PagesOptions): PagesState => ({
    rows: options.defaultRows,
    focused: null,
    moving: null,
  }),
  api: (context) => {
    const change = createChange(context)
    return {
      roving: createRovingFocus<PagesState, { value: string }>(context, {
        slot: "row",
        key: "focused",
      }),
      tree: createTreeReorder<PagesState>(context, {
        key: "rows",
        draft: "moving",
        set: (value, details) => change("rows", value, details),
        indent: () => 20,
        preview: true,
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { rows: TreeReorderRow[] }>({
      state: (state, { api }) => ({ rows: api.tree.rows(state) }),
      attrs: () => ({ role: "tree" }),
    }),
    row: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => {
        const depth =
          part.api.tree.rows(state).find((row) => row.key === part.key)
            ?.depth ?? 0
        return {
          role: "treeitem",
          "aria-level": String(depth + 1),
          tabindex: part.api.roving.tabIndex(state, part),
          "data-moving": part.api.tree.moving(state, part.key) ? "" : undefined,
          style: css({
            "--depth": String(depth),
            "touch-action": "none",
            "user-select": "none",
          }),
        }
      },
      on: {
        keydown: (event, { el, api, key }) =>
          api.tree.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) => api.tree.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.tree.bind(el, part.key),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
  }),
})

const parts = createParts(pages, { root: "div", row: "div" })

const Pages = parts.Root

const PagesRow = parts.Row

const outline = [
  { key: "Guides", depth: 0 },
  { key: "Install", depth: 1 },
  { key: "Forms", depth: 1 },
  { key: "Reference", depth: 0 },
  { key: "Hooks", depth: 1 },
  { key: "Changelog", depth: 0 },
]

function Example() {
  return (
    <Pages aria-label="Pages" defaultRows={outline}>
      {({ rows }) =>
        rows.map((row) => (
          <PagesRow key={row.key} value={row.key}>
            {row.key}
          </PagesRow>
        ))
      }
    </Pages>
  )
}

export { Example }
Drag an outline row up or down, and sideways to change its depth. With the keyboard, pick it up with Space, use Up and Down to place it, and Left and Right to change its level.

indent is the horizontal distance for one level, 24 pixels by default. Closed subtrees can remain in the data while their rows are unmounted; pointer targeting skips those hidden rows.

A move won't change a neighboring row's parent. Depth is limited by the rows around the insertion point. The set details include key, parent (null at the root), and the zero-based index among that parent's children, which is useful when your application stores a nested tree.

Limits and Moving from Code

Add a maximum depth and a pickup rule:

maxDepth: () => 1,
disabled: (key) => key === "Inbox",

Depth starts at zero, so this allows two levels. The limit includes the moving row's descendants.

import * as React from "react"
import {
  createChange,
  createRovingFocus,
  createTreeReorder,
  css,
  definePrimitive,
  subtreeEnd,
  treeReorderMessage,
  type TreeReorderDraft,
  type TreeReorderRow,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import {
  ArrowLeftIcon,
  ArrowRightIcon,
  FolderIcon,
  InboxIcon,
} from "lucide-react"

type FoldersState = {
  rows: TreeReorderRow[]
  focused: string | null
  moving: TreeReorderDraft | null
}

const counts: Record<string, number> = {
  Inbox: 12,
  Work: 4,
  Reports: 2,
  Drafts: 1,
  Personal: 7,
  Travel: 3,
}

const folders = definePrimitive({
  name: "folders",
  state: (): FoldersState => ({
    rows: [
      { key: "Inbox", depth: 0 },
      { key: "Work", depth: 0 },
      { key: "Reports", depth: 1 },
      { key: "Drafts", depth: 1 },
      { key: "Personal", depth: 0 },
      { key: "Travel", depth: 1 },
    ],
    focused: null,
    moving: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const tree = createTreeReorder<FoldersState>(context, {
      key: "rows",
      draft: "moving",
      set: (value, details) => change("rows", value, details),
      indent: () => 16,
      maxDepth: () => 1,
      disabled: (key) => key === "Inbox",
      label: (key) => key,
      message: (message) =>
        message.type === "drop"
          ? `${message.label} moved, level ${message.level}.`
          : treeReorderMessage(message),
    })
    return {
      roving: createRovingFocus<FoldersState, { value: string }>(context, {
        slot: "row",
        key: "focused",
      }),
      tree,
      shift(key: string, by: number) {
        const rows = store.get().rows
        const index = rows.findIndex((row) => row.key === key)
        const after = rows[subtreeEnd(rows, index)]?.key ?? null
        tree.move(key, after, rows[index].depth + by)
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { rows: TreeReorderRow[] }>({
      state: (state, { api }) => ({ rows: api.tree.rows(state) }),
      attrs: () => ({ role: "tree" }),
    }),
    row: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => {
        const rows = part.api.tree.rows(state)
        const index = rows.findIndex((row) => row.key === part.key)
        return {
          role: "treeitem",
          "aria-level": String(rows[index].depth + 1),
          tabindex: part.api.roving.tabIndex(state, part),
          "data-moving": part.api.tree.moving(state, part.key) ? "" : undefined,
          "data-dragging": part.api.tree.dragging(state, part.key)
            ? ""
            : undefined,
          "data-parent": subtreeEnd(rows, index) > index + 1 ? "" : undefined,
          style: css({
            "--depth": String(rows[index].depth),
            "touch-action": "none",
            "user-select": "none",
          }),
        }
      },
      on: {
        keydown: (event, { el, api, key }) =>
          api.tree.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) => api.tree.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.tree.bind(el, part.key),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
    indent: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (_state, part) => ({
        type: "button",
        tabindex: "-1",
        "aria-label": `Indent ${part.key}`,
      }),
      on: { pointerdown: (event) => event.preventDefault() },
      press: ({ api, key }) => api.shift(key, 1),
    }),
    outdent: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (_state, part) => ({
        type: "button",
        tabindex: "-1",
        "aria-label": `Outdent ${part.key}`,
      }),
      on: { pointerdown: (event) => event.preventDefault() },
      press: ({ api, key }) => api.shift(key, -1),
    }),
  }),
})

const parts = createParts(folders, {
  root: "div",
  row: "div",
  indent: "button",
  outdent: "button",
})

const Folders = parts.Root

const FoldersRow = parts.Row

const FoldersIndent = parts.Indent

const FoldersOutdent = parts.Outdent

function Example() {
  return (
    <Folders aria-label="Folders">
      {({ rows }) =>
        rows.map((row) => (
          <FoldersRow key={row.key} value={row.key}>
            {row.key === "Inbox" ? (
              <InboxIcon aria-hidden="true" />
            ) : (
              <FolderIcon aria-hidden="true" />
            )}
            <span>{row.key}</span>
            <span data-slot="hint">{counts[row.key]}</span>
            {row.key !== "Inbox" && (
              <>
                <FoldersOutdent value={row.key}>
                  <ArrowLeftIcon aria-hidden="true" />
                </FoldersOutdent>
                <FoldersIndent value={row.key}>
                  <ArrowRightIcon aria-hidden="true" />
                </FoldersIndent>
              </>
            )}
          </FoldersRow>
        ))
      }
    </Folders>
  )
}

export { Example }
Use a folder's arrow buttons to indent or outdent it with its contents. The tree allows two levels, and Inbox can't be picked up.

tree.move(key, before, depth) moves a subtree before another row, or to the end when before is null. It uses the nearest depth that fits. subtreeEnd(rows, index) returns the first index after a subtree, which helps calculate where to insert it.

For a staged move, use grab, place, then drop or cancel. moving(state, key) includes descendants; dragging also checks that the move came from a pointer. Customize label and message as for a list, with treeReorderMessage as the default wording.

Moving Between Lists

Two Lists

Store the lists together as a Record<string, string[]>, with item keys unique across all lists. Add a TransferDraft | null field for the move:

const transfer = createTransfer(context, {
  key: "lists",
  draft: "moving",
  set: (value, details) => change("lists", value, details),
})

Render from transfer.lists(state). Bind list elements with bindList(el, key) and item elements with bindItem(el, key), returning their cleanups from the slots. Keep the existing focus binding, and run transfer.keydown(event, key) before its key handler. A press calls transfer.pointerdown(event, key).

import * as React from "react"
import {
  createChange,
  createRovingFocus,
  createTransfer,
  definePrimitive,
  spatialMove,
  type ChangeDetails,
  type TransferDraft,
  type TransferLists,
  type TransferReason,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

const names: Record<string, string> = { closet: "Closet", bag: "Bag" }

type PackingState = {
  lists: TransferLists
  focused: string | null
  moving: TransferDraft | null
}

type PackingOptions = {
  defaultLists: TransferLists
  onListsChange?: (
    lists: TransferLists,
    details: ChangeDetails<TransferReason>
  ) => void
}

const packing = definePrimitive({
  name: "packing",
  options: { defaultLists: "value", onListsChange: "callback" },
  state: (options: PackingOptions): PackingState => ({
    lists: options.defaultLists,
    focused: null,
    moving: null,
  }),
  api: (context) => {
    const change = createChange(context)
    return {
      roving: createRovingFocus<PackingState, { value: string }>(context, {
        slot: "item",
        key: "focused",
        move: spatialMove(),
      }),
      transfer: createTransfer<PackingState>(context, {
        key: "lists",
        draft: "moving",
        set: (value, details) => change("lists", value, details),
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { lists: TransferLists }>({
      state: (state, { api }) => ({ lists: api.transfer.lists(state) }),
    }),
    list: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ role: "listbox" }),
      bind: (el, { api, key }) => api.transfer.bindList(el, key),
    }),
    item: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        "aria-selected": String(state.moving?.key === part.key),
        tabindex: part.api.roving.tabIndex(state, part),
        "data-dragging": part.api.transfer.dragging(state, part.key)
          ? ""
          : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          api.transfer.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) =>
          api.transfer.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.transfer.bindItem(el, part.key),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
  }),
})

const parts = createParts(packing, { root: "div", list: "div", item: "div" })

const Packing = parts.Root

const PackingList = parts.List

const PackingItem = parts.Item

function Example() {
  return (
    <Packing
      defaultLists={{
        closet: ["Jacket", "Boots", "Scarf"],
        bag: ["Passport", "Charger"],
      }}
    >
      {({ lists }) =>
        Object.entries(lists).map(([list, items]) => (
          <div key={list} data-slot="transfer-lane">
            <span data-slot="transfer-label">{names[list]}</span>
            <PackingList value={list} aria-label={names[list]}>
              {items.map((item) => (
                <PackingItem key={item} value={item}>
                  {item}
                </PackingItem>
              ))}
            </PackingList>
          </div>
        ))
      }
    </Packing>
  )
}

export { Example }
Drag an item between packing lists. With the keyboard, pick it up with Space, use Left and Right to change lists and Up and Down to place it, then Space to drop it.

Transfer uses a dragged copy and auto-scrolls near edges. moving(state, key) identifies the picked-up item, while dragging(state, key) identifies a pointer drag. The set details include the item's key, its from and to lists, and its new index.

A Board with Rules

Use accepts to reject a destination, and order to choose how the keyboard travels between lists:

order: () => ["todo", "doing", "done"],
accepts: (key, list) =>
  list !== "doing" || context.store.get().lists.doing.filter((card) => card !== key).length < 2,
disabled: (key) => key === "Billing",
listLabel: (list) => columns[list],
import * as React from "react"
import {
  createChange,
  createRovingFocus,
  createTransfer,
  definePrimitive,
  spatialMove,
  transferMessage,
  type TransferDraft,
  type TransferLists,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { CheckIcon } from "lucide-react"

type BoardState = {
  lists: TransferLists
  focused: string | null
  moving: TransferDraft | null
}

const columns: Record<string, string> = {
  todo: "To do",
  doing: "Doing",
  done: "Done",
}
const blocked = "Billing"
const tags: Record<string, string> = {
  Search: "Feature",
  Billing: "Blocked",
  Login: "Bug",
  Docs: "Chore",
  Icons: "Design",
}

const board = definePrimitive({
  name: "board",
  state: (): BoardState => ({
    lists: {
      done: ["Icons"],
      todo: ["Search", "Billing", "Login"],
      doing: ["Docs"],
    },
    focused: null,
    moving: null,
  }),
  api: (context) => {
    const change = createChange(context)
    return {
      roving: createRovingFocus<BoardState, { value: string }>(context, {
        slot: "card",
        key: "focused",
        move: spatialMove(),
      }),
      transfer: createTransfer<BoardState>(context, {
        key: "lists",
        draft: "moving",
        set: (value, details) => change("lists", value, details),
        order: () => ["todo", "doing", "done"],
        accepts: (key, list) =>
          list !== "doing" ||
          context.store.get().lists.doing.filter((card) => card !== key)
            .length < 2,
        disabled: (key) => key === blocked,
        label: (key) => key,
        listLabel: (list) => columns[list],
        message: (message) =>
          message.type === "drop"
            ? `${message.label} is in ${message.list} now.`
            : transferMessage(message),
        createAutoScroll: false,
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { lists: TransferLists }>({
      state: (state, { api }) => ({ lists: api.transfer.lists(state) }),
    }),
    column: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ role: "listbox" }),
      bind: (el, { api, key }) => api.transfer.bindList(el, key),
    }),
    card: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        "aria-selected": String(part.api.transfer.moving(state, part.key)),
        "aria-disabled": part.key === blocked ? "true" : undefined,
        tabindex: part.api.roving.tabIndex(state, part),
        "data-dragging": part.api.transfer.dragging(state, part.key)
          ? ""
          : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          api.transfer.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { api, key }) =>
          api.transfer.pointerdown(event, key),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.transfer.bindItem(el, part.key),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
    finish: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (_state, part) => ({
        type: "button",
        tabindex: "-1",
        "aria-label": `Move ${part.key} to Done`,
      }),
      on: { pointerdown: (event) => event.preventDefault() },
      press: ({ api, key }) => api.transfer.move(key, "done", 0),
    }),
  }),
})

const parts = createParts(board, {
  root: "div",
  column: "div",
  card: "div",
  finish: "button",
})

const Board = parts.Root

const BoardColumn = parts.Column

const BoardCard = parts.Card

const BoardFinish = parts.Finish

function Example() {
  return (
    <Board>
      {({ lists }) =>
        Object.keys(columns).map((list) => (
          <div key={list} data-slot="transfer-lane">
            <span data-slot="transfer-label">
              {columns[list]}
              {list === "doing" && <span>Max 2</span>}
            </span>
            <BoardColumn value={list} aria-label={columns[list]}>
              {lists[list].map((card) => (
                <BoardCard key={card} value={card}>
                  {card}
                  <div data-slot="transfer-meta">
                    {tags[card]}
                    {list !== "done" && card !== blocked && (
                      <BoardFinish value={card}>
                        <CheckIcon aria-hidden="true" />
                      </BoardFinish>
                    )}
                  </div>
                </BoardCard>
              ))}
            </BoardColumn>
          </div>
        ))
      }
    </Board>
  )
}

export { Example }
Move cards between columns. Doing holds at most two cards, Billing can't be picked up, and the check on a card moves it straight to Done.

transfer.move("Search", "done", 0) makes an immediate move. For a staged move, use grab, moveTo(list, index), then drop or cancel.

label names items, listLabel names destinations, and message customizes announcements with transferMessage as the default. Set createAutoScroll: false for a board that doesn't need edge scrolling.

Dragging a Copy

For your own drag, bindDragPreview creates a copy of an element that follows the pointer. The original stays in place:

const preview = bindDragPreview(el, { x: event.clientX, y: event.clientY })

In the drag's onMove, call preview.move(x, y). In onEnd and any early cleanup, call preview.remove(). You can style the copy through preview.el, for example by toggling a data-over attribute over a valid target.

import * as React from "react"
import {
  bindDrag,
  bindDragPreview,
  createChange,
  definePrimitive,
  dragThreshold,
  type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type TaggerState = { value: string[]; over: boolean }

type TaggerOptions = {
  onValueChange?: (
    value: string[],
    details: ChangeDetails<"drag" | "item-press">
  ) => void
}

const tagger = definePrimitive({
  name: "tagger",
  options: { onValueChange: "callback" },
  state: (_options: TaggerOptions): TaggerState => ({ value: [], over: false }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const add = (
      tag: string,
      details: { reason: "drag" | "item-press"; event?: Event }
    ) =>
      !store.get().value.includes(tag) &&
      change("value", [...store.get().value, tag], details)
    let stop: (() => void) | undefined
    context.onDestroy(() => stop?.())
    return {
      pointerdown(event: PointerEvent, el: HTMLElement, tag: string) {
        if (event.button !== 0) {
          return
        }
        const note = context.element("note")
        let preview: ReturnType<typeof bindDragPreview> | undefined
        const isOver = (x: number, y: number) => {
          const box = note?.getBoundingClientRect()
          return (
            !!box &&
            x >= box.left &&
            x <= box.right &&
            y >= box.top &&
            y <= box.bottom
          )
        }
        stop?.()
        stop = bindDrag(el, event, {
          threshold: dragThreshold,
          cancelOnEscape: true,
          onStart: () => {
            preview = bindDragPreview(el, {
              x: event.clientX,
              y: event.clientY,
            })
          },
          onMove: ({ x, y }) => {
            const over = isOver(x, y)
            preview?.move(x, y)
            preview?.el.toggleAttribute("data-over", over)
            store.set({ over })
          },
          onEnd: (end, canceled) => {
            const { over } = store.get()
            preview?.remove()
            stop = undefined
            store.set({ over: false })
            if (preview && over && !canceled) {
              add(tag, { reason: "drag", event: end })
            }
          },
        })
      },
      keydown(event: KeyboardEvent, tag: string) {
        if (event.key !== "Enter") {
          return
        }
        event.preventDefault()
        add(tag, { reason: "item-press", event })
      },
    }
  },
  slots: (slot) => ({
    tag: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({
        type: "button",
        style: "touch-action:none;user-select:none",
      }),
      on: {
        pointerdown: (event, { el, api, key }) =>
          api.pointerdown(event, el!, key),
        keydown: (event, { api, key }) => api.keydown(event, key),
      },
    }),
    note: slot<{}, { tags: string[] }>({
      state: (state) => ({ tags: state.value }),
      attrs: (state) => ({ "data-over": state.over ? "" : undefined }),
    }),
  }),
})

const parts = createParts(tagger, { tag: "button", note: "div" })

const Tagger = parts.Root

const TaggerTag = parts.Tag

const TaggerNote = parts.Note

function Example() {
  return (
    <Tagger>
      <div data-slot="stack">
        <div data-slot="row">
          {["Urgent", "Design", "Later"].map((tag) => (
            <TaggerTag data-size="sm" key={tag} value={tag}>
              {tag}
            </TaggerTag>
          ))}
        </div>
        <TaggerNote aria-label="Note">
          {({ tags }) => (
            <>
              <span data-slot="label">Launch plan</span>
              <div data-slot="row">
                {tags.length ? (
                  tags.map((tag) => (
                    <span key={tag} data-slot="badge">
                      {tag}
                    </span>
                  ))
                ) : (
                  <span data-slot="hint">Drag a tag here.</span>
                )}
              </div>
            </>
          )}
        </TaggerNote>
      </div>
    </Tagger>
  )
}

export { Example }
Drag a tag onto the note and release it when the copy is highlighted. You can also focus a tag and press Enter to add it.

The copy preserves the element's appearance when created; it doesn't render future framework updates. It has data-drag-preview, stripped IDs, and is hidden from assistive technology and pointer targeting. It uses the top layer when available, with a fixed-position fallback.

Scrolling While Dragging

A custom drag can use createAutoScroll to scroll near the pointer. Create it for the drag, call update(x, y) on each move, and call stop() on every exit:

const scroll = createAutoScroll({
  edge: 48,
  speed: 16,
  ignore: () => preview.el,
  onScroll: findChapter,
})
Console
import * as React from "react"
import {
  bindDrag,
  bindDragPreview,
  createAutoScroll,
  createChange,
  definePrimitive,
  dragThreshold,
  type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { BookmarkIcon } from "lucide-react"
import { useLog } from "./log"

type BookState = { bookmark: string | null; over: string | null }

type BookOptions = {
  onBookmarkChange?: (
    bookmark: string | null,
    details: ChangeDetails<"drag">
  ) => void
}

const book = definePrimitive({
  name: "book",
  options: { onBookmarkChange: "callback" },
  state: (_options: BookOptions): BookState => ({ bookmark: null, over: null }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    let stop: (() => void) | undefined
    context.onDestroy(() => stop?.())
    return {
      pointerdown(event: PointerEvent, el: HTMLElement) {
        if (event.button !== 0) {
          return
        }
        let preview: ReturnType<typeof bindDragPreview> | undefined
        let pointer = { x: event.clientX, y: event.clientY }
        const findChapter = () => {
          const hit = document
            .elementFromPoint(pointer.x, pointer.y)
            ?.closest<HTMLElement>("[data-chapter]")
          store.set({ over: hit?.dataset.chapter ?? null })
        }
        const scroll = createAutoScroll({
          edge: 48,
          speed: 16,
          ignore: () => preview?.el,
          onScroll: findChapter,
        })
        stop?.()
        stop = bindDrag(el, event, {
          threshold: dragThreshold,
          cancelOnEscape: true,
          onStart: () => {
            preview = bindDragPreview(el, pointer)
          },
          onMove: ({ x, y }) => {
            pointer = { x, y }
            preview?.move(x, y)
            scroll.update(x, y)
            findChapter()
          },
          onEnd: (end, canceled) => {
            const { over } = store.get()
            scroll.stop()
            preview?.remove()
            stop = undefined
            store.set({ over: null })
            if (preview && over && !canceled) {
              change("bookmark", over, { reason: "drag", event: end })
            }
          },
        })
      },
    }
  },
  slots: (slot) => ({
    bookmark: slot({
      attrs: () => ({
        type: "button",
        style: "touch-action:none;user-select:none",
      }),
      on: { pointerdown: (event, { el, api }) => api.pointerdown(event, el!) },
    }),
    chapters: slot({ attrs: () => ({ role: "list" }) }),
    chapter: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "listitem",
        "data-chapter": part.key,
        "data-over": state.over === part.key ? "" : undefined,
        "data-bookmarked": state.bookmark === part.key ? "" : undefined,
      }),
    }),
  }),
})

const parts = createParts(book, {
  bookmark: "button",
  chapters: "div",
  chapter: "div",
})

const Book = parts.Root

const BookBookmark = parts.Bookmark

const BookChapters = parts.Chapters

const BookChapter = parts.Chapter

const chapters = Array.from(
  { length: 24 },
  (_, index) => `Chapter ${index + 1}`
)

function Example() {
  const log = useLog()
  return (
    <Book
      onBookmarkChange={(bookmark) =>
        log(bookmark ? `Bookmarked ${bookmark}` : "Removed the bookmark")
      }
    >
      <div data-slot="stack">
        <BookBookmark data-size="sm">
          <BookmarkIcon aria-hidden="true" />
          Bookmark
        </BookBookmark>
        <BookChapters aria-label="Chapters">
          {chapters.map((chapter) => (
            <BookChapter key={chapter} value={chapter}>
              {chapter}
            </BookChapter>
          ))}
        </BookChapters>
      </div>
    </Book>
  )
}

export { Example }
Drag the bookmark near the chapter list's top or bottom edge. Keep holding it there to scroll, then release it over a chapter.

edge is the distance in pixels where scrolling begins; speed is the maximum pixels per frame. The helper finds the nearest scroller under the pointer, with vertical page scrolling as a fallback. ignore skips an element such as the dragged copy. onScroll lets you find the target again after the content moves under a stationary pointer.

Keeping Focus and the Draft

The sorting behaviors already preserve focus and keep a committed draft visible while a controlled value catches up. If you're building your own drag, use keepFocus after moving a focused element and settleDraft when handing a draft to its owner.

API Reference

The option tables link back to the examples that introduce them.

onMove(sample: PointerSample) => void
At most once per frame, with the latest position and the movement since the last call.
Example: Follow a Pointer
onEnd(event: PointerEvent, canceled: boolean) => void
The pointer lifted, or the browser took it over, like for a scroll. A move still pending runs first, so the last position is never lost.
Example: Follow a Pointer
threshold?number
How far, in CSS pixels, the pointer moves along either axis before moves are reported, so a press that barely moves stays a click. Defaults to 0.
Example: Follow a Pointer
onStart?((event: PointerEvent) => boolean | void)
Runs once, on the first move past threshold, before its onMove. Return false to stop following the pointer, like when a drag can't start.
Example: Dragging Without Limits
lock?boolean
Locks the pointer once it has moved past dragThreshold, so it can travel without limit, like a scrub, while a plain click never hides the cursor. Falls back to capture where the browser refuses, as Safari does.
Example: Dragging Without Limits
cancelOnEscape?boolean
Ends the gesture as canceled when Escape is pressed once it has started, and keeps that press from reaching anything else, like a popup around it. onEnd gets the last pointer event. Defaults to false.
Example: Follow a Pointer
slotstring
The slot whose parts are the items. Each joins in the slot's bind with reorder.bind(el, part).
Example: Sort a List
keykeyof State & string
The state key that holds the order, a string[] of item keys.
Example: Sort a List
draftkeyof State & string
The state key that holds the ReorderDraft while an item moves. Start it at null.
Example: Sort a List
set(value: string[], details: { reason: ReorderReason; event?: Event; }) => boolean
Asks for a new order and returns whether it went through. Pass the primitive's change, so on<Key>Change, cancel() and controlled values work: set: (value, details) => change("value", value, details).
Example: Sort a List
orientation?(() => ReorderOrientation)
Whether the items run top to bottom, along a line, or wrap in rows like a grid of photos, which picks the arrows and how a drag finds its place. Defaults to "vertical".
Example: Grids
group?((key: string) => string[])
The keys that move with the one picked up, like the selection when it holds it. Defaults to that one key.
Example: Several at Once
preview?boolean
Drags a copy of the item above the page while the item itself shows where it would drop, so a list that scrolls neither clips it nor grows under it. A drag near a scroller's edge scrolls it. Defaults to false.
Example: A Copy While Dragging
createAutoScroll?false | Omit<AutoScrollOptions, "ignore" | "onScroll">
How a drag with preview near a scroller's edge scrolls it, or false not to.
Example: A Copy While Dragging
disabled?((item: { key: string; props: Props; }) => boolean)
Whether an item can't be picked up. It still moves aside for others.
Example: What's Read Out
label?((key: string) => string | undefined)
The name screen readers hear for an item. Return undefined for the default, its text.
Example: What's Read Out
message?((message: ReorderMessage) => string)
The text screen readers hear for each step. Defaults to English.
Example: What's Read Out
keykeyof State & string
The state key that holds the rows, a TreeReorderRow[] in reading order.
Example: Sort and Nest
draftkeyof State & string
The state key that holds the TreeReorderDraft while an item moves. Start it at null.
Example: Sort and Nest
set(value: TreeReorderRow[], details: { reason: TreeReorderReason; event?: Event; key: string; } & TreePlace) => boolean
Asks for the new rows and returns whether they went through. Pass the primitive's change: set: (value, details) => change("rows", value, details). details says which item moved and where to.
Example: Sort and Nest
indent?(() => number)
How far a drag goes sideways for each level, in CSS pixels. Defaults to 24.
Example: Sort and Nest
maxDepth?(() => number)
The deepest an item and the rows under it can go, from 0. Defaults to no limit.
Example: Limits and Moving from Code
disabled?((key: string) => boolean)
Whether an item can't be picked up. It still moves aside for others.
Example: Limits and Moving from Code
label?((key: string) => string | undefined)
The name screen readers hear for an item. Defaults to its text.
Example: Limits and Moving from Code
message?((message: TreeReorderMessage) => string)
The text screen readers hear for each step. Defaults to English.
Example: Limits and Moving from Code
preview?boolean
Drags a copy of the row above the page while the rows show where it would drop. Defaults to false.
Example: Sort and Nest
createAutoScroll?false | Omit<AutoScrollOptions, "ignore" | "onScroll">
How a drag with preview near a scroller's edge scrolls it, or false not to.
Example: Sort and Nest
keykeyof State & string
The state key that holds every list, a TransferLists.
Example: Two Lists
draftkeyof State & string
The state key that holds the TransferDraft while an item moves. Start it at null.
Example: Two Lists
set(value: TransferLists, details: { reason: TransferReason; event?: Event; key: string; from: string; to: string; index: number; }) => boolean
Asks for the new lists and returns whether they went through. Pass the primitive's change: set: (value, details) => change("lists", value, details). details says what moved, from where to where.
Example: Two Lists
order?(() => string[])
The lists in the order Left and Right go through them. Defaults to the order of the value's keys.
Example: A Board with Rules
accepts?((key: string, list: string) => boolean)
Whether a list takes an item, like a column at its limit. Lists that don't are skipped.
Example: A Board with Rules
disabled?((key: string) => boolean)
Whether an item can't be picked up.
Example: A Board with Rules
label?((key: string) => string | undefined)
The name screen readers hear for an item. Defaults to its text.
Example: A Board with Rules
listLabel?((list: string) => string | undefined)
The name screen readers hear for a list. Defaults to its key.
Example: A Board with Rules
message?((message: TransferMessage) => string)
The text screen readers hear for each step. Defaults to English.
Example: A Board with Rules
createAutoScroll?false | Omit<AutoScrollOptions, "ignore" | "onScroll">
How a drag near a scroller's edge scrolls it, or false not to.
Example: A Board with Rules
edge?number
How far in from an edge scrolling starts, in pixels.
Example: Scrolling While Dragging
speed?number
The fastest it scrolls, in pixels per frame, with the pointer at the edge.
Example: Scrolling While Dragging
onScroll?(() => void)
Runs after each frame that scrolled, so a drag can find its target again under a pointer that didn't move.
Example: Scrolling While Dragging
ignore?(() => Element | null | undefined)
The element being dragged, which sits under the pointer, so the scrollers under it are found instead of its own.
Example: Scrolling While Dragging