Drag and drop

The shared contract for keyed collection drag/drop in ListBox, Table, TreeView, Sidebar, Tabs and Accordion.

Choose a control

Configure drag/drop once on the persistent SDK control or builder. Looks supply themed native sources, targets and previews while the SDK owns session admission and completion guards. Rebuilding a Look adapter reuses registrations; keep application collection state and controls across renders.

  • ListBox: host-owned keyed reorder and transfer, with automatic event routing in Shadcn and Radix.
  • Table: SDK-owned same-table row reordering, automatic commits and an optional host policy override; also available through PagingTable, Shadcn, and Radix.
  • TreeView: host-owned subtree moves within or between trees, retaining hierarchy and destination locations; SDK, Shadcn and Radix.
  • Sidebar: SDK-owned sibling, group and subtree moves in expanded navigation; SDK, Shadcn, and Radix.
  • Tabs: SDK-owned same-strip reordering with retained content; SDK, Shadcn and Radix.
  • Accordion: SDK-owned same-control item reordering through header drag handles, with retained disclosure/content state; SDK, Shadcn, and Radix.

The linked control pages contain setup, exact domain payloads, compatibility notes and a compiled integration example. Collection drag/drop is opt-in. Slider, scrollbar, splitter, resize-handle and text-selection drags retain their gesture-specific contracts.

Proposals and completion

on_drop handles a proposed operation. Validate the live session, current keys and application policy, prepare all affected snapshots, apply data and selection/state changes, then synchronously commit or reject. on_dropped observes an accepted, completed operation after all those changes. Use completion for notifications or persistence, rather than making the proposal handler do both jobs.

ListBox and TreeView hosts own transactions and report their outcome through proposal completion methods. Accordion validates and commits automatically unless set_drop_handler overrides that behavior. Table validates and commits automatically unless set_row_drop_handler overrides that behavior. Unanswered synchronous handlers are rejected. Do not defer mutation or retain a proposal to complete it after the handler returns.

A valid no-op completes with changed=false and the end reason Unchanged; it emits no mutation phases. Rejection leaves data unchanged and has its own rejected/end outcome. Cancellation ends the source session without rejection or completion. A gesture that never reaches an admitted proposal, including a foreign scope, can cancel without emitting rejection. Completion, rejection and preview-release cancellation share one guard, so retained clones cannot finish a session twice.

Callback registration and ownership

Use on_event(cx, callback) for the control event stream, on_drag_drop(cx, callback) for its nested drag/drop lifecycle, and the named helpers below for individual phases. Aggregate and named callbacks coexist. Table retains on_row_drag as a compatible aggregate alias.

Register once on a persistent SDK control/builder; factory-created Table and TreeView entities also expose SDK event extension traits. ListBox handlers live on ListBoxControl, alongside its selection callbacks. The control owns callback registrations, holds the application owner weakly, and releases captures when the control is released. Capture owning entities weakly to avoid cycles. Retained native previews and proposals do not retain emitting controls.

Registrations append in order within a binding. Delivery occurs after the current control/owner borrow ends, permitting callbacks to update the emitting control. Separate subscriptions follow GPUI dispatch order. Callback arguments are the owner, payload fields in the table, and the owner context; compound payloads are borrowed for that callback only, while scalar values are copied. Clone borrowed values to retain them.

Lifecycle and payloads

The shared event is DragDropEvent<S, K>. Persistent ListBox, Table and Tabs callbacks use EntityId collection identities; source and target identify controls rather than host entities or render-time elements. ListBox uses its configured key type K; Table uses SharedString. before=None is an append gap. Sidebar projects destination phases into SidebarDropLocation { group_id, parent_id, before }. TreeView projects every phase into its domain payloads, preserving locations and normalized subtree roots.

Shared eventNamed callbackListBox / Table payloadRecipient and timing
DragStartedon_drag_startedsource: EntityId, keys: &Vec<K>Source: native threshold crossed; no mutation.
ItemsRemovedon_items_removedsource: EntityId, target: EntityId, keys: &Vec<K>Source: committed transfer removal, before target addition.
ItemsAddedon_items_addedsource: EntityId, target: EntityId, keys: &Vec<K>, before: &Option<K>Target: committed transfer addition, before completion.
ItemsReorderedon_items_reorderedtarget: EntityId, keys: &Vec<K>, before: &Option<K>Target: changed move within the same control, before completion.
Droppedon_droppedsource: EntityId, target: EntityId, keys: &Vec<K>, before: &Option<K>, changed: boolTarget: accepted completion, including a valid no-op.
DropRejectedon_drop_rejectedsource: EntityId, target: EntityId, keys: &Vec<K>, before: &Option<K>, error: &SharedStringTarget: rejected proposal, before end.
DragEndedon_drag_endedsource: EntityId, keys: &Vec<K>, reason: DragEndReasonSource: one terminal outcome, including cancellation.

All notifications in a committed batch observe the completed host transaction. Mutation/completion keys use host-reported destination order; start and end retain the drag’s captured order. TreeView retains normalized moving roots in displayed order, with descendants carried by their ancestor’s subtree.

OutcomeOrdered phases after startEnd reason
Changed transferRemoved (source) → added (target) → dropped (target) → ended (source)Transferred
Changed move within one controlReordered → dropped → endedReordered
Valid no-opDropped → endedUnchanged
Rejected proposalRejected (target) → ended (source)Rejected
Cancelled gestureEnded (source)Cancelled

Tree reparenting is a move within the same control. Its compatibility reason names map Reordered → MovedWithinTree and Transferred → MovedAcrossTrees; other reasons retain their meaning. Table currently does not enable cross-table transfer, although its shared event type and named helpers can represent transfer phases.

Scopes, validation and interaction

A scope admits cooperating surfaces. Use one shared scope for collections allowed to exchange compatible payloads; different scopes reject admission. Matching scope IDs alone do not make different control domains interoperable. ListBox also requires a shared key type, and TreeView a shared domain type. TreeView and Table perform their domain validation; ListBox hosts validate current keys and application policy in addition to proposal lifetime checks.

GPUI owns native gesture thresholds, hit testing and previews. SDK bindings provide keyed gaps and cancellation; Looks expose the applicable targets and auto-scroll behavior. Native preview release, including Escape cancellation through the SDK focus actions, closes an unfinished session. Embedded controls should use the SDK drag boundary so their clicks and native drags remain independent of the enclosing row.

Table, Sidebar, Tabs and Accordion resolve drag appearance through their theme’s resolve_drag hook. Native previews use the floating surface and foreground, with theme typography and radius. Valid and rejected insertion feedback use the Look’s focus and destructive colors rather than ordinary label colors. Custom themes can override the hook.

Motion after a drop

Changed drops animate mounted items from their previous keyed positions to the committed layout. The shared SDK DropLayoutMotion uses VisualTransition with 200 ms cubic ease-out by default. Data, selection and lifecycle callbacks commit immediately; animation does not delay Dropped or DragEnded. Valid no-ops, rejected drops and cancellations do not start motion.

Configure [common.motion.<control>.drop_layout] in the look stylesheet or edit the role with .motion(...). Untouched roles inherit the look defaults. Transition::instant() disables one role; motion_enabled(false) disables all roles. GPUI reduced motion also settles immediately. Inspect authored transitions through motion_profile().

SDK · drop motion
use xgpui_luma::{controls::tabs::Tabs, motion::Transition};

fn settings_tabs() -> xgpui_luma::controls::tabs::TabsBuilder {
    Tabs::new("settings")
        .motion(|m| m.drop_layout = Transition::spring(300.0, 28.0))
}
ControlSupported motionDomain differences
AccordionMoved and displaced whole sectionsKeyed expansion, disclosure progress, focus, measurements and child entities survive. Header drags keep editor input independent.
TabsMoved and displaced buttons within the same stripActive ID, focus, retained page entities and body fades survive. One stable insertion marker remains independent of motion.
SidebarSibling reorder, group moves and reparented visible rowsVisible descendants track their own IDs. Collapsed destinations mount children only when expanded; collapsed rails remain navigation-only.
Table / PagingTableVisible moved and displaced keyed rowsPage and viewport state remain owned by the table. Access PagingTable’s inner list() for its motion settings.
TreeViewVisible rows displaced by reorder, reparenting or supported transferFlattened row-root positions animate; depth/indentation styling reflows immediately. Newly revealed or transferred-in rows without local geometry appear in their final position.
ListBoxReorder and displaced siblings in source/destination listsPersistent Shadcn and Radix adapters start motion from committed mutation phases. Original raw Shadcn composition retains a DropLayoutMotion<K>, supplies it with .drop_layout_motion(handle.clone()), and calls start() once per changed list after applying the complete transaction.

Motion translates the real keyed elements and their hit boxes; it does not clone presenters or keep ghost views. Both previous and new positions must be mounted and visible to animate. No off-screen rows are created for animation. Geometry is pruned each frame and capped at 512 visible identities per control; additional rows snap. Supported cross-control transfers animate surviving/displaced siblings locally, without a flying row or subtree between controls.

New source drags, explicit data replacement, scrolling, viewport resize and duration/presentation changes settle active motion. If live geometry changes during the transition (for example, a branch expands), it settles to the new layout. Retained selection/focus/expansion/scroll and content remain authoritative. Removed or newly mounted keys never reuse another row’s geometry. After settlement no animation frames are scheduled.

Custom templates wrap the viewport with motion.scope(content, scroll_offset_reader) and each row with motion.item(key, element). Sidebar’s RenderedNavNode.drop_layout supplies its handle; static previews use None. Native/default templates already apply these bindings.

Investigation decision (#140): use shared position transitions, enabled with existing motion policy, because stable IDs and retained viewport geometry are already available in all five controls. Limit the first implementation to mounted positions. Full ghost/cross-window animations and interpolation of row size or tree indentation are deferred because they require separate ownership, measurement and hit-testing policies.

Luma Studio Controls provides Theme, Slow (800 ms) and Off buttons for Tabs, Sidebar, scrolling Table, TreeView workspaces and ListBox transfer examples. Slow mode makes displacement and interruption observable; tab counters demonstrate retained content.

Control and Look coverage

SurfaceExisting APIResulting APISupported phasesCompatibility / differences
SDK ListBox controlSeparate composition callbackson_event → on_drag_drop → seven named helpers; ListBoxDragDrop / ListBoxDropProposalAll seven; reorder and transferHost-owned snapshots; commits route notifications automatically
Shadcn ListBoxdrag_and_drop(on_drop, on_event, preview)from_control and render_listbox consume SDK configurationAll sevenOriginal raw-composition APIs remain compatible; migration shown in ListBox docs
Radix ListBoxNo built-in collection DnDnew consumes SDK control configuration and native bindingsAll sevenSame persistent registration and transaction APIs as Shadcn
SDK Table / PagingTable builders and entitieson_row_drag; set_row_drop_handler; commit/reject_row_dropon_drag_drop alias + seven named helpers; existing APIs retainedStart, reorder, drop, reject, end; transfer filters availableSame-table reorder only; paging IDs are inner table IDs
Shadcn / Radix Tableon_row_drag; SDK entity commitson_drag_drop alias + seven named helpersSame as SDK TableRadix table_builder returns the SDK builder; row-key and commit APIs remain on the entity.
SDK / Shadcn / Radix TreeViewon_drag_drop; TreeViewDragDrop; committed/rejected; four public phasesSame aggregate + all seven named helpers and committed mutation phasesAll seven; same-tree moves and cross-tree transferTree locations/normalized roots/reason names preserved; exhaustive matches need new arms
SDK / Shadcn / Radix SidebarNo collection DnDdrag_drop; set_drop_handler; commit/reject_drop; on_event → on_drag_drop → seven named helpersStart, reorder, drop, reject, endSame-sidebar subtree/group moves; structured location; expanded presentation only; Radix sidebar_builder retains the SDK contract
SDK / Shadcn / Radix TabsNo collection DnDreordering; set_drop_handler; commit/reject_drop; on_event → on_drag_drop → seven named helpersStart, reorder, drop, reject, endSame-strip only; retained content and active identity; flat before gap
SDK / Shadcn / Radix AccordionExpansion and content statereordering; set_drop_handler; commit/reject_drop; aggregate + seven named lifecycle helpersStart, reorder, drop, reject, endSame-control whole-item moves; header handles; retained keyed disclosure/content state. Radix accordion_builder retains the SDK contract.
Other native drag controlsSlider/scrollbar, splitters/panels, column resize, slide/overlay handles, text selectionExisting gesture-specific callbacks unchangedScalar/geometry/selection gesturesNo collection drop proposals; no synthetic collection lifecycle

Accordion, Sidebar and Tabs do not emit unsupported removal/addition phases. The inventory distinguishes collection transactions from existing scalar and geometry drags; adding support to another control is separate work.

Source

Shared keyed lifecycle · ListBox integration · Tree projection