DragDrop
Device-agnostic drag-and-drop for Roblox UI. A singleton that moves an opaque
Payload from a registered source to a registered target across mouse,
touch, gamepad, and keyboard, emitting intent through the target's OnDrop.
It never inspects a payload past its Tags, so one registration handles any
kind of content.
Register a source and/or a target on a GuiObject and keep the returned
Unregister alive for as long as the element should participate. In a
component framework, insert it into your cleanup scope:
local DragDrop = require(Packages.DragDrop)
local disconnectSource = DragDrop.RegisterSource(slot, {
GetPayload = function()
return { Tags = { "Item" }, Guid = guid, FromIndex = i }
end,
BuildGhost = function(payload, source) return MyItemGhost(payload) end,
OnActivated = openContextMenu, -- click / A-press without a lift
})
local disconnectTarget = DragDrop.RegisterTarget(slot, {
Accepts = { "Item" }, -- only takes item drags
OnDrop = function(payload, target) MoveItem(payload, target) end,
Metadata = { Index = i },
})
Where to read more
-
DD Getting Started — registering sources and
targets, how
Tags/Accepts/CanDropdecide compatibility, and reacting to drag state. - DD Input Devices — the gesture per device, the configurable thresholds and keybinds, and what switching input class mid-drag does.
-
DD Ghosts & Scripting — customizing the
drag proxy with
BuildGhost, and driving drags by hand through DragDrop.SetInputMode and DragHandle.
Types
Payload
type Payload = {Tags: {string},[string]: any}
The opaque value carried by a drag. Tags is the only field the system reads:
it lists the payload's kinds for target compatibility — a target's Accepts
takes the drag if it shares at least one tag. Everything else is yours to
shape (guids, indices, a display name for your ghost, ...).
Because Tags travels with the payload and GetPayload runs on every lift, a
source whose contents vary can return different tags per lift — e.g. a bag
slot returns { "Item", "Food" } for a ration and { "Item", "Weapon" } for
a sword, and each matches the right target.
TargetDescriptor
interface TargetDescriptor {Metadata: {[any]: any}--
Whatever Metadata the target registered with.
}Handed to a target's OnDrop and returned by DragDrop.GetHoveredTarget.
InputMode
type InputMode = "Automatic" | "Scriptable"
Automatic (default) wires the pointer and selection backends to real device
input. Scriptable disables them so the only way a drag starts or moves is
through DragDrop.BeginDrag and the returned DragHandle — like putting a
camera in Scriptable mode.
Config
interface Config {DragThresholdPx: number?--
Mouse: movement past this (in pixels) lifts. Default 8.
TouchSlopPx: number?--
Touch: movement past this before the hold fires = scroll, not lift. Default 12.
LongPressSeconds: number?--
Touch: hold duration that lifts. Default 0.4.
SnapSpeed: number?--
Selection mode: slot-to-slot ghost spring speed. Default 30.
GhostContainer: LayerCollector?--
Parent for ghost mounts. nil = an internal high-DisplayOrder ScreenGui.
GamepadLiftKeyCode: Enum.KeyCode?--
Button that lifts the selected source. Default ButtonX.
GamepadDropKeyCode: Enum.KeyCode?--
Button that drops on the selected target. Default ButtonA.
GamepadCancelKeyCode: Enum.KeyCode?--
Button that cancels a drag. Default ButtonB.
KeyboardLiftKeyCode: Enum.KeyCode?--
Key that toggles lift/drop in grab mode. Default Return.
KeyboardCancelKeyCode: Enum.KeyCode?--
Optional explicit keyboard cancel key. Default nil.
}Behavior knobs only — nothing here styles the ghost (that lives in BuildGhost).
DragHandle
interface DragHandle {SetHoveredTarget: (target: GuiObject?) → boolean--
Set hover explicitly; false if the target is invalid/incompatible.
Drop: (self: DragHandle) → boolean--
End on the current hover; false if there was no valid target (reverted).
}A programmatic controller for a single drag, returned by DragDrop.BeginDrag. Every method becomes a no-op (and the mutating ones return false) once the drag has ended.
Unregister
type Unregister = () → ()Returned by DragDrop.RegisterSource and DragDrop.RegisterTarget. Call it to tear the registration down.
Properties
ActivePayloadChanged
Fires whenever the airborne payload changes — the new payload on lift, nil on drop or cancel.
It does not fire on connect, so read the current value with
DragDrop.GetActivePayload when you subscribe. It is a plain [Signal], so it
works with anything that consumes signals — :Once, :Wait, a Janitor, a
reactive binding.
-- Dim every incompatible slot while a drag is live. Seed from the getter, then
-- keep it in sync from the signal.
local function repaint(payload: DragDrop.Payload?)
for _, slot in slots do
slot.Dimmed = payload ~= nil and not accepts(slot, payload)
end
end
repaint(DragDrop.GetActivePayload())
local conn = DragDrop.ActivePayloadChanged:Connect(repaint)
-- later:
conn:Disconnect()
HoveredTargetChanged
Fires whenever the hovered target changes — the new TargetDescriptor on enter, nil on leave.
It does not fire on connect, so read the current value with DragDrop.GetHoveredTarget when you subscribe.
-- Move a "drop here" highlight to whatever the ghost is over.
local conn = DragDrop.HoveredTargetChanged:Connect(function(target)
highlight.Visible = target ~= nil
if target then highlight.Parent = target.Instance end
end)
DragStarted
Fires when a payload goes airborne.
DragDrop.DragStarted:Connect(function(payload, source)
playPickupSound()
source.BackgroundTransparency = 0.6 -- fade the origin slot while lifted
end)
Dropped
Fires when a payload lands on a valid target, immediately after that target's
OnDrop has run.
-- A global listener for analytics, independent of any one target's OnDrop.
DragDrop.Dropped:Connect(function(payload, target)
Analytics:Log("item_moved", {
guid = payload.Guid,
to = target.Metadata.Index,
})
end)
DragEnded
Fires on every drag end. A nil target means the drag was cancelled or reverted.
This is the last event of a drag. End-of-drag order:
-
Reactive state clears —
GetActivePayload/GetHoveredTargetgo nil and DragDrop.ActivePayloadChanged/DragDrop.HoveredTargetChanged fire (ghost/scroll-lock teardown has already run). - The target's
OnDropruns, then DragDrop.Dropped fires. - The source's
OnDragEndruns, thenDragEndedfires.
Because those change signals fire before OnDrop mutates your data, a view
that repaints only from them will still show pre-drop data — repaint on
DragEnded (or from your own data layer) to pick up the result of the drop.
DragDrop.DragEnded:Connect(function(payload, target)
if target == nil then
playRevertSound() -- cancelled or reverted
end
repaintInventory() -- OnDrop has already applied the move by now
end)
Functions
RegisterSource
Types
interface SourceOptions {GetPayload: () → Payload?--
Returns the payload to lift, or nil to refuse the lift (empty slot, disabled, ...). Called on press.
OnActivated: (() → ())?--
Press + release without crossing the drag threshold (a "click").
OnDragEnd: ((dropped: boolean) → ())?--
Fired on every drag end. dropped=false means cancelled/reverted.
}
Marks instance (and its descendants) as something that can be picked up.
Returns a function that unregisters it; the source also auto-unregisters if the
instance is destroyed.
local unregister = DragDrop.RegisterSource(slot, {
GetPayload = function()
if slot.IsEmpty then return nil end -- refuse the lift
return { Tags = { "Item" }, Guid = slot.Guid, FromIndex = i }
end,
BuildGhost = function(payload) return MyItemGhost(payload) end,
OnActivated = function() openContextMenu(i) end, -- click without a lift
})
-- later, when the slot goes away:
unregister()
RegisterTarget
Types
interface TargetOptions {Accepts: {string}?--
Tags this target accepts; matches if it shares at least one tag with the payload's Tags. Empty or omitted accepts any payload.
Metadata: {[any]: any}?--
Arbitrary data surfaced back through TargetDescriptor.Metadata.
}
Marks instance (and its descendants) as somewhere a payload can be dropped.
Returns a function that unregisters it; the target also auto-unregisters if the
instance is destroyed.
local unregister = DragDrop.RegisterTarget(slot, {
Accepts = { "Item" }, -- only item drags; omit to accept any payload
CanDrop = function(payload) return not slot.Locked end, -- finer gate
OnDrop = function(payload, target)
MoveItem(payload.Guid, target.Metadata.Index)
end,
OnHoverChanged = function(payload)
slot.Highlight.Visible = payload ~= nil -- nil on leave
end,
Metadata = { Index = i },
})
BeginDrag
Lifts a registered source programmatically and returns a DragHandle, or nil
if the lift is refused (not registered, GetPayload returned nil, or a drag is
already active).
-
With
position: a pointer-controlled drag whose ghost follows the point you feed viahandle:MoveTo. The automatic backends leave it alone. -
Without
position: a selection grab-mode drag. In Automatic mode the gamepad/keyboard backend drives it (selection nav moves the ghost); in Scriptable mode you drive it withhandle:SetHoveredTargetandhandle:Drop.
-- Pointer-controlled: feed the ghost a position yourself.
local handle = DragDrop.BeginDrag(slot, UserInputService:GetMouseLocation())
if handle then
handle:MoveTo(UserInputService:GetMouseLocation()) -- each frame
handle:Drop() -- lands on whatever it's hovering
end
-- Selection grab-mode (no position): drive it explicitly under Scriptable mode.
local handle = DragDrop.BeginDrag(sourceSlot)
if handle then
handle:SetHoveredTarget(destSlot)
handle:Drop()
end
SetInputMode
Switches between "Automatic" (device backends wired to real input) and
"Scriptable" (backends off; drags driven only through DragDrop.BeginDrag).
Any active drag is cancelled across the switch.
-- In a test: take control away from real input, then drive a drag by hand.
DragDrop.SetInputMode("Scriptable")
local handle = DragDrop.BeginDrag(sourceSlot)
handle:SetHoveredTarget(destSlot)
handle:Drop()
DragDrop.SetInputMode("Automatic") -- hand input back
GetInputMode
if DragDrop.GetInputMode() == "Automatic" then
-- real device input is live
end
Drop
DragDrop.Drop() → boolean
Ends the active drag on the currently hovered target. Returns true if it
landed on a valid target, false if there was none (a revert). No-op when idle.
-- Bind a "confirm" key to commit whatever drag is in flight.
local landed = DragDrop.Drop()
if not landed then
-- reverted: nothing valid under the ghost
end
Cancel
DragDrop.Cancel() → ()Reverts the active drag, or aborts a pending press. No-op when idle.
-- Escape backs out of whatever is in flight.
UserInputService.InputBegan:Connect(function(input)
if input.KeyCode == Enum.KeyCode.Escape then
DragDrop.Cancel()
end
end)
IsDragging
DragDrop.IsDragging() → boolean-- Suppress a hover tooltip while something is airborne.
if not DragDrop.IsDragging() then
showTooltip(slot)
end
GetActivePayload
The payload currently airborne, or nil when idle.
local payload = DragDrop.GetActivePayload()
if payload and table.find(payload.Tags, "Item") then
-- an item is in flight right now
end
GetHoveredTarget
The valid target the drag is currently over, or nil.
local target = DragDrop.GetHoveredTarget()
if target then
print("would drop into slot", target.Metadata.Index)
end
ConsumeActivation
DragDrop.ConsumeActivation() → boolean
True if a drag ended within the last few hundredths of a second — use it to
swallow a stray Button.Activated that fires right after a drop, so a drag
doesn't also read as a click.
button.Activated:Connect(function()
if DragDrop.ConsumeActivation() then
return -- this "click" was really the tail of a drag; ignore it
end
openSlot()
end)
Configure
Applies a partial Config over the current settings. Errors on unknown keys
or wrong value types. Behavior knobs only — ghost appearance lives in
SourceOptions.BuildGhost.
DragDrop.Configure({
DragThresholdPx = 12, -- a little more slop before a mouse lift
LongPressSeconds = 0.3, -- quicker touch lift
GhostContainer = playerGui.DragLayer, -- mount ghosts here
})
ResetConfig
DragDrop.ResetConfig() → ()
Restores every Config key to its default. This is also the only way to
clear an optional key such as GhostContainer — Configure
cannot accept nil for them, since nil values vanish from Luau tables.
DragDrop.Configure({ GhostContainer = myLayer })
-- ...
DragDrop.ResetConfig() -- back to defaults, GhostContainer cleared to nil