Drag & Drop: Mouse vs. Gamepad
Gameface does not implement the HTML5 drag-and-drop API. Mouse drag-and-drop is built from pointer events directly, or via the Interaction Manager’s dropzone module for slot-based inventories. Gamepad players need a two-press flow: pick up, navigate, drop. While the item is being carried, spatial navigation pauses and the D-Pad moves the item instead of the focus.
The native HTML5 trap
Section titled “The native HTML5 trap”In a standard browser, you would reach for draggable="true" and the dragstart / dragover / drop event family. Gameface does not implement
that API surface. Those attributes and events do nothing, and there is no engine flag that turns them on.
What does work is pointer events. Drag-and-drop in Gameface is always built from mousedown, mousemove, and mouseup (plus the gamepad
equivalent for the second half of this article), with the dragged element positioned absolutely and moved by your own code.
Mouse drag-and-drop
Section titled “Mouse drag-and-drop”For mouse input, the manual pointer-event path gives you full control. IM’s dropzone module handles the standard inventory-to-slot case with less
code.
The manual approach
Section titled “The manual approach”The minimum pointer-event recipe is three handlers. mousedown on the draggable element captures the start offset between the pointer and the
element’s top-left corner. mousemove on window updates the element’s transform so it follows the pointer. mouseup on window releases the
drag and resolves the drop.
import Block from '@components/Layout/Block/Block';
const Inventory = () => ( <Block class="inventory"> <Block class="slot" data-slot="0" /> <Block class="slot" data-slot="1" /> <Block class="slot" data-slot="2"> <Block class="item" id="potion" /> </Block> </Block>);<div class="slot" data-slot="0"></div><div class="slot" data-slot="1"></div><div class="slot" data-slot="2"> <div class="item" id="potion"></div></div>import { createSignal, onMount, onCleanup } from 'solid-js';import Block from '@components/Layout/Block/Block';
type Pos = { x: number; y: number };
const DraggableItem = () => { let itemRef!: HTMLElement; const [dragging, setDragging] = createSignal(false); const [pos, setPos] = createSignal<Pos | null>(null); let offset: Pos = { x: 0, y: 0 };
const onMouseDown = (e: MouseEvent) => { const rect = itemRef.getBoundingClientRect(); offset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; setDragging(true); }; const onMouseMove = (e: MouseEvent) => { if (!dragging()) return; setPos({ x: e.clientX - offset.x, y: e.clientY - offset.y }); }; const onMouseUp = (e: MouseEvent) => { if (!dragging()) return; setDragging(false); setPos(null); const target = document.elementFromPoint(e.clientX, e.clientY); const slot = target?.closest('.slot') as HTMLElement | null; if (slot) slot.appendChild(itemRef); };
onMount(() => { window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); }); onCleanup(() => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); });
return ( <Block ref={itemRef} id="potion" class={`item${dragging() ? ' is-dragging' : ''}`} style={pos() ? { transform: `translate(${pos()!.x}px, ${pos()!.y}px)` } : undefined} onMouseDown={onMouseDown} /> );};const item = document.getElementById("potion");let dragging = false;let offsetX = 0;let offsetY = 0;
function onMouseDown(event) { dragging = true; const rect = item.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; item.classList.add("is-dragging");}
function onMouseMove(event) { if (!dragging) return; item.style.transform = `translate(${event.clientX - offsetX}px, ${event.clientY - offsetY}px)`;}
function onMouseUp(event) { if (!dragging) return; dragging = false; item.classList.remove("is-dragging");
// Resolve drop. Which slot is under the pointer right now? const target = document.elementFromPoint(event.clientX, event.clientY); const slot = target?.closest(".slot"); if (slot) slot.appendChild(item);}
item.addEventListener("mousedown", onMouseDown);window.addEventListener("mousemove", onMouseMove);window.addEventListener("mouseup", onMouseUp);Use this approach when the interaction has rules the IM modules do not cover.
Using IM’s dropzone
Section titled “Using IM’s dropzone”The Interaction Manager’s dropzone module handles the drag, the hit-test against a list of target slots, and the drop resolution in one constructor call. For typical inventory UIs it replaces the manual recipe entirely. It is part of the same Interaction Manager library introduced in Keyboard & Gamepad Input.
import { dropzone } from "coherent-gameface-interaction-manager";
const item = new dropzone({ element: ".item", dropzones: [".slot"], dragClass: "is-dragging", dropzoneActiveClass: "is-target", dropType: "switch", onDrop: (event) => { console.log("Dropped on", event.dropzone); },});A few of these options carry most of the value:
dragClassis applied to the carried element while it is being dragged. Use it for the lift, shadow, or tilt that tells the player the drag is in progress.dropzoneActiveClassis applied to whichever drop target is currently under the carried element. Use it to highlight the slot the item will land in.- dropType is the option you will tune most often. It decides what happens when the target slot is not empty:
dropType | Behavior |
|---|---|
| add (default) | Places the carried item into the dropzone. |
| switch | Swaps the carried item with whatever is already in the slot. |
| shift | Pushes the carried item in and shifts neighbors to make space. |
| none | Rejects the drop, returning the item to its starting position. |
The onDrop callback fires once the drop resolves. The event object exposes target (the dropped element), dropzone (the
receiving slot), and preventDefault(). The last one is useful when the drop needs server confirmation before committing the visual change.
onDrop: (event) => { event.preventDefault(); // hold the visual commit until the backend agrees confirmInventoryMove(event.target, event.dropzone);};Call item.deinit() to remove the listeners when the screen unmounts. For touch support, flip item.touchEnabled = true and see the
Touch Support article for the broader picture.
Gamepad drag-and-drop
Section titled “Gamepad drag-and-drop”A gamepad has no equivalent of click-and-hold, so the same action that takes one gesture on a mouse splits into two button presses with navigation in between. The player presses confirm on a focused item to pick it up, uses the D-Pad to move it, and presses confirm again to drop it.
The piece that needs the most care is what happens to directional input in the middle. While the item is being carried, the D-Pad has to move the item rather than the focus, which means spatial navigation needs to step aside and custom direction handlers take over until the drop. The walkthrough below builds that flow for a grid-based inventory where every slot holds one item.
-
Pick up the focused item on confirm
Section titled “Pick up the focused item on confirm”A single confirm handler toggles between pick-up and drop based on whether something is currently carried. On pick-up, the handler captures the focused element, pauses spatial navigation, installs the temporary direction handlers, and adds a visual class.
import { spatialNavigation, gamepad } from "coherent-gameface-interaction-manager";// Reference to the item the player is currently holding. Null when idle.let carried = null;// One handler covers both pick-up and drop.function onConfirm() {if (carried) drop();else pickUp();}function pickUp() {carried = document.activeElement;carried.classList.add("is-carried");// Without this pause, the D-Pad would still move focus instead of the item.spatialNavigation.pause();installCarryControls();}// Wire the confirm handler on initialization.gamepad.on({ actions: ["face-button-down"], type: "press", callback: onConfirm }); -
Move the carried item with the D-Pad
Section titled “Move the carried item with the D-Pad”With spatial navigation paused, the direction keys are free for the move handlers. Each one finds the neighboring slot and puts the carried item into it.
// Registered on pick-up so the handlers only intercept the D-Pad while an item is held.function installCarryControls() {gamepad.on({ actions: ["pad-right"], type: "press", callback: moveRight });gamepad.on({ actions: ["pad-left"], type: "press", callback: moveLeft });gamepad.on({ actions: ["pad-down"], type: "press", callback: moveDown });gamepad.on({ actions: ["pad-up"], type: "press", callback: moveUp });}function moveRight() {const nextSlot = carried.parentElement.nextElementSibling;// Guard so the item only lands on real slot elements, not stray siblings.if (nextSlot?.classList.contains("slot")) nextSlot.appendChild(carried);} -
Drop & cleanup on the second confirm press
Section titled “Drop & cleanup on the second confirm press”The second confirm press calls
onConfirmagain which callsdropbecausecarriedis non-null. The item is already in its destination slot, so the drop only cleans up.function drop() {carried.classList.remove("is-carried");removeCarryControls();// Hand the D-Pad back to spatial navigation.spatialNavigation.resume();// Forget the reference so the next confirm press picks up again.carried = null;}// Tears down every direction handler that pickUp installed.function removeCarryControls() {gamepad.off(["pad-right"], moveRight);gamepad.off(["pad-left"], moveLeft);gamepad.off(["pad-down"], moveDown);gamepad.off(["pad-up"], moveUp);}
Free-positioning layouts
Section titled “Free-positioning layouts”The snap-to-slot pattern works well for simple 1x1 grids. For Tetris-style inventories where each item occupies a different slice of the grid (1x3, 2x2, 3x3), the approach needs to change.
Each item tracks its top-left grid cell (gridX, gridY) and its size in cells (cols, rows). On every D-Pad press, the move handler shifts
those coordinates and clamps them so the item’s edges stay inside the grid. The element follows by writing the new pixel offset to transform.
function moveBy(dx, dy) { // Shift the top-left corner and clamp so the item's far edges stay inside the grid. carried.gridX = clamp(carried.gridX + dx, 0, gridWidth - carried.cols); carried.gridY = clamp(carried.gridY + dy, 0, gridHeight - carried.rows);
carried.style.transform = `translate(${carried.gridX * cellSize}px, ${carried.gridY * cellSize}px)`;}The drop also changes. Instead of appending to a known slot, find the slot the carried item overlaps most and commit there.
// Get the slots beforehandconst slots = document.querySelectorAll(".slot");
function resolveDrop() { const carriedRect = carried.getBoundingClientRect(); let best = null; let bestOverlap = 0;
// Find the slot with the most overlap with the carried item. slots.forEach((slot) => { const overlap = rectOverlap(carriedRect, slot.getBoundingClientRect()); if (overlap > bestOverlap) { bestOverlap = overlap; best = slot; } });
if (best) best.appendChild(carried);}Reusing the dropzone module
Section titled “Reusing the dropzone module”If the mouse path is already wired with dropzone, extending it for gamepad follows the same pattern covered above: pick up the item, pause spatial
navigation, move it with the D-Pad. The only part that changes is the drop. Reparenting the item directly into a slot bypasses what dropzone is
tracking internally, so the next mouse drag will produce wrong results. Route the drop through automaticAction instead, which
fires a programmatic drop through the same dropType policy and onDrop callback the mouse path already uses.
import { dropzone, actions } from "coherent-gameface-interaction-manager";
// Set up the dropzone as normal for mouse input.const inventoryDrop = new dropzone({ element: ".item", dropzones: [".slot"] });
// On gamepad drop, call the same action that dropzone calls on mouse drop.function resolveDrop(targetSlotIndex) { actions.execute(inventoryDrop.automaticAction, { elementIndex: 0, dropzoneIndex: targetSlotIndex, });}© 2026 Coherent Labs. All rights reserved.