Skip to content

[drag-engine] Add drag and drop primitives - #5487

Open
flaviendelangle wants to merge 71 commits into
mui:masterfrom
flaviendelangle:drag-engine
Open

[drag-engine] Add drag and drop primitives#5487
flaviendelangle wants to merge 71 commits into
mui:masterfrom
flaviendelangle:drag-engine

Conversation

@flaviendelangle

@flaviendelangle flaviendelangle commented Aug 13, 2026

Copy link
Copy Markdown
Member

Documentation
Feature comparison with dnd kit, React Aria, and Pragmatic drag and drop

Summary

Adds unstyled drag-and-drop primitives for React:

  • Draggable.Root creates a pointer and keyboard drag source.
  • DropTarget.Root accepts matching drag kinds.
  • DragAutoScroll.Root configures a scroll container or implements custom scrolling.
  • useDragMonitor observes matching drags across the page.
  • useDragDropManager registers sources, targets, monitors, and auto-scrollers imperatively.

Basic usage

const card = Draggable.createKind<Card>("card");

<Draggable.Root kind={card} payload={cardData} label={cardData.title} />;

<DropTarget.Root
  accept={card}
  label="Done"
  onDrop={({ source }) => move(source.payload.id)}
/>;

Draggable.Root supports keyboard dragging by default. Sources registered with useDragDropManager must be made focusable by the application.

Drag state is exposed through data attributes and CSS variables. Movement modifiers apply to pointer and keyboard drags. Once auto-scroll is enabled, Base UI detects scrollable containers without individual registrations.

@flaviendelangle flaviendelangle self-assigned this Aug 13, 2026
@flaviendelangle flaviendelangle added type: new feature Expand the scope of the product to solve a new problem. scope: all components Widespread work has an impact on almost all components. labels Aug 13, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 13, 2026

Copy link
Copy Markdown

commit: 292a9d6

@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 7552c18
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a885f26f8e6b00008156a24
😎 Deploy Preview https://deploy-preview-5487--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 13, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+66.8KB(+14.68%) 🔺+22.1KB(+14.90%)

Details of bundle changes

Performance

Total duration: 1,063.33 ms -118.91 ms(-10.1%) | Renders: 76 (+0) | Paint: 1,723.65 ms -202.00 ms(-10.5%)

Test Duration Renders
Select mount (200 instances) 149.39 ms 🔺+28.54 ms(+23.6%) 3 (+0)
Combobox open — 500 items 33.42 ms 🔺+5.76 ms(+20.8%) 4 (+0)
Slider mount (300 instances) 97.38 ms ▼-73.91 ms(-43.1%) 2 (+0)
Checkbox mount (500 instances) 58.02 ms ▼-45.33 ms(-43.9%) 1 (+0)
Popover mount (300 instances) 35.07 ms ▼-26.43 ms(-43.0%) 1 (+0)

…and 1 more (+9 within noise) — details

Metric alarms

Test Metric Change
Select mount (200 instances) bench:paint 🔺 +39.32 ms
Combobox open — 500 items bench:paint#combobox-open 🔺 +18.08 ms
Combobox open — 500 items bench:paint 🔺 +18.08 ms

Check out the code infra dashboard for more information about this PR.

@flaviendelangle

Copy link
Copy Markdown
Member Author

CI follow-up: the repo-wide llms-full.txt generation memory fix has been extracted to #5488. The equivalent patch remains on this branch temporarily so this PR can validate cleanly; it will fall out of the diff once #5488 lands.

@mnajdova mnajdova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some useful feedback extract from initial AI review.

// Breaking layouts get a new shared-slot protocol; additive fields must be
// backfilled instead, because bumping the protocol would split the live store
// and registries between the two copies.
slot.sourceStore ??= new Store<DragSource | null>(slot.store.state?.source ?? null);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review worth consindering:
Hand-maintained mirror store sourceStore duplicates what a useStore selector on the main store already provides. Severity: 🟠 significant waste. The file keeps a second Store<DragSource | null> plus sourceSnapshot/sourceVersion bookkeeping, synced by a module-scope subscription (slot.sourceStoreSubscription ??= slot.store.subscribe((state) => {... slot.sourceStore.setState(source); })). failure_scenario: ~30 lines of mirror plumbing plus an import-time subscription that runs on every session update forever, existing only because updateDragSourceElement mutates state.source.element in place and then must force a wake via slot.sourceStore.setState({ ...state.source }) — an Object.is selector on the main store would miss the mutation. Smaller form: make the retarget immutable (setState({ ...state, source: { ...state.source, element: newElement } })) and let consumers use the existing useStore(dragSessionStore, (s) => s?.source ?? null) selector path (packages/utils/src/store/useStore.ts already skips re-renders on unchanged selection); sourceStore, sourceSnapshot, sourceVersion syncing, and the backfill block all disappear.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I checked this. I think sourceStore is worth keeping. The main session store changes whenever the hovered targets change, but source consumers only need updates when a drag starts, ends, or its source node is replaced. A selector would prevent React re-renders, but it would still wake every subscriber on each target change. The displacement code also subscribes to this store directly.

The in-place source.element update is intentional too. The lifecycle and sensors keep the original DragSource object and read it later. Replacing only the source in the session store would leave that code pointing at the old, detached element after a virtualized row is remounted.

sourceVersion is separate from this mirror and is needed for target state updates in React 18. I think the right follow-up is to keep the current design and add a short comment and focused test to make these constraints clearer.

🤖 Comment generated with Codex

* A per-target session view. Movement wakes only targets in the old/new hover
* stack instead of synchronously running selectors for every target on the page.
*/
export function createDragTargetStateStore(): DragTargetStateStore {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review worth considering:
Third hand-rolled pub/sub in the same file: per-element listener buckets (targetListeners: Map<Element, Set<() => void>>, allTargetListeners) plus a bit-packed numeric snapshot with a version multiplier (value + slot.sourceVersion * dragTargetStateStride). Severity: 🟡 moderate. failure_scenario: one file now contains three notification mechanisms (session Store, mirror Store, manual keyed emitter), and setDragSession manually fans out to the right buckets (~35 lines of listener-set union logic). The targeted-wake design is genuinely performance-motivated (avoids running N selectors per pointer frame), so this cannot literally be one Store — but the get-or-create/delete-when-empty Set-in-Map bucket code is written twice within the file (subscribe and setElement both inline let set = slot.targetListeners.get(...); if (!set) { set = new Set(); ... }). Smaller form: a tiny keyed-emitter helper (addToSetMap/removeFromSetMap, or a KeyedStore<Element, number> built on Store) so the bucket maintenance exists once; the sourceVersion * stride trick would then live behind one named API instead of being spread across getSnapshot, setDragSession, and the consumer's selector mask.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point on the repeated bucket setup. I extracted that part into a local addToElement helper in 64062a0, next to the existing removal helper.

I kept the targeted fan-out and sourceVersion logic. They avoid waking unrelated drop targets during pointer movement and make the accepting state update correctly in React 18. A broader KeyedStore abstraction would add more code than this small case needs.

I verified the change with the drag session and drop target tests, Prettier, ESLint, and TypeScript.

🤖 Comment generated with Codex

settings: ResolvedDragPreview<any> | null;
}

const slot = getSharedSlot<ActivePreviewSlot>('activeDragPreview', () => ({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review worth considering:
Three separate global slots all track "the active drag's preview": the activeDragPreview slot ({ handle, settings }), the per-provider Store<DragPreviewState | null>, and the dragPreview.lastPublished slot ({ store }). Severity: 🟡 moderate. failure_scenario: overlapping caches of the same drag-start resolution — activePreview stores settings: ResolvedDragPreview explicitly "so React never resolves them a second time", while DragPreviewState re-carries offset/sourceRect/input that were part of, or derived alongside, that same resolution; and clearPublishedDragPreview exists only because the clearing code can't reach whichever provider store received the content ("Clear the preview the previous drag published, whichever store it went to"). Smaller form: a single global Store<ActivePreview | null> whose state carries a provider identity (the context object) alongside handle/settings/node/host; each PreviewOverlayRenderer subscribes to the one store and renders only when state?.provider === myContext. That collapses createDragPreviewStore, publishDragPreview's dual bookkeeping, and the lastPublished slot, and makes drag-end clearing a plain setState(null).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 17d294888. I consolidated the React-rendered preview into one shared store. Each PreviewProvider now renders only the preview that belongs to it, so the separate provider stores and the extra “last published” tracker are gone.

I kept activeDragPreview separate because it has a different job. The drag engine creates that handle before a drag is accepted. If a second pickup is refused, it must restore the previous handle without changing the preview already on screen. It also updates and removes the actual preview element, while the shared store only chooses which React content to render.

I added coverage for switching the active preview between providers and clearing it. The preview, sensor, and store tests pass in jsdom and Chromium. Prettier, ESLint, and TypeScript also pass.

🤖 Comment generated with Codex

ref(node);
if (node) {
const observer = new (ownerWindow(node).MutationObserver)(refreshAutoScroll);
observer.observe(node, {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review worth considering:
always-on per-container MutationObserver overlapping the engine's drag-scoped idle observer - the hook wires new (ownerWindow(node).MutationObserver)(refreshAutoScroll) with { attributes, attributeFilter: ['class','style'], childList: true, subtree: true } for the whole time the component is mounted, while autoScroller.ts observeIdleMutations() (L852–876) already installs an observer with the identical config (attributeFilter: ['class','style'], childList: true, subtree: true) on doc.documentElement — a superset subtree — whenever the loop parks during a drag.

You can likely drop the hook-level observer entirely and let the engine's idle observer (which already covers this element's subtree and is scoped to parked-during-drag) be the single wake path; if a non-parked mid-drag restyle must be caught, attach/detach the hook's observer from the engine's drag start/end instead of component mount/unmount.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6afef7575. The main concern was valid: the per-container observer no longer runs for the whole time DragAutoScroll.Root is mounted. The engine now starts it with a pointer drag and disconnects it when the drag ends. Both observer paths also share one options object.

I did not remove the per-container observer completely. The parked document observer cannot see inside shadow roots, and it is not present while auto-scroll is already moving. A class or style change during that time still needs to clear the cached overflow and direction values.

I added tests for the observer lifetime and for a container changing from scrollable to hidden during an active drag. The root suite passes in jsdom and Chromium, the manager registration suite passes, and Prettier, ESLint, and TypeScript pass.

🤖 Comment generated with Codex

* here would strand the sensor and refuse every later pickup. Degrade to "nothing
* under the pointer" instead.
*/
export function deepElementFromPoint(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method re-implements the jsdom-safe elementFromPoint wrapper that exists as getElementAtPoint. Can we update the method so it can be used here as well?

@flaviendelangle flaviendelangle Aug 20, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 👌

@mnajdova mnajdova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some runtime performance considerations brought up by AI:

  • autoScroller.ts:852-876 — parked-loop MutationObserver resets style caches + rebuilds the scroller chain on any page class/style mutation during a drag.
  • cloneDragPreview.ts:184-193 — full computed-style copy per custom-element node can hitch the pickup frame on web-component-heavy sources.
  • autoScroller.ts:584-604 — O(registered scrollers) rect reads per awake scroll frame; adopt the keyboard sensor's invalidation-based rect cache.
  • utils.ts:126-160 — document/warn the per-frame double hit-test cost when preview content sets pointer-events: auto.

@flaviendelangle

flaviendelangle commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

I followed up on this performance review in 8571464f9.

  • I kept the parked-loop document observer. It is one-shot and exists only after auto-scroll has stopped requesting frames. It needs the document scope because content anywhere on the page can change the page scroll range. The previous fix in 6afef7575 already limits the per-container observers to active pointer drags.
  • I kept the full computed-style copy for custom elements. A smaller property list can change the placeholder's size or layout. The docs now explain the pickup cost and recommend a lightweight Draggable.Preview for items containing many custom elements.
  • I did not add the keyboard sensor's rect cache to auto-scroll. The keyboard cache helps across repeated key presses while layout stays still. An awake auto-scroll loop moves content every frame, which invalidates those rects every frame, so the cache would add state without removing the reads.
  • The docs now say to keep pointer-events: none on preview children and explain that pointer-events: auto makes Base UI hide the preview and repeat the hit test on each drag frame.

🤖 Comment generated with Codex

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 21, 2026
flaviendelangle and others added 8 commits August 21, 2026 07:57
Deduplicate tab reordering, capture-handler prologues, and canDrop
callbacks; use clamp and fastObjectShallowCompare from @base-ui/utils;
read keyboard direction from the event instead of cursor deltas; move
tree updates and queries into the model; only scan folders while the
"Move to" submenu is open; stop re-creating the overflow ResizeObserver
on every tree change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	docs/scripts/generateLlmTxt/index.mjs
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 21, 2026
flaviendelangle and others added 9 commits August 21, 2026 14:39
Remove dead code (unused observeElement channel, getDeclaredDropTargetPayload,
selectDragSource, documentBinding defer options, test-only preview shim),
collapse duplicated logic (data attribute constants, terminal-leave payload,
scroll notification, kind constructors, latched cleanups), and trim per-frame
work (lazy consumer error message, scroll-extent check before computed style).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: all components Widespread work has an impact on almost all components. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants