Drag And Drop Inventory in s&box
S&box recently got a Drag And Drop API, so let's take a look at how to build a grid-based inventory with drag and drop — with it, or sometimes despite it.

Getting Started
Recently I implemented one myself, the end result is visible in the title image above. Here, though, we'll aim for something simpler: a grid inventory with DnD from scratch, where you can place an item and move it around — that's enough to cover the s&box specifics.
Let's start with the grid layout:
- The intuitive approach: make an NxM grid of square cells with
border-width: 1pxbetween them. We're skipping this one, because every element would have its own border, and they'd stack to give you two pixels instead of one. - The non-intuitive approach: same NxM slots, but use
gap: 1pxfor the grid, and let the panel behind the cells show through the gaps to form the grid lines.
Code:
Inventory.razor:
@inherits PanelComponent
<root>
<div class="outline">
<div class="background">
@for (var i = 0; i < Rows; i++)
{
<div class="row">
@for (var j = 0; j < Cols; j++)
{
<InventoryCell/>
}
</div>
}
</div>
</div>
</root>
@code {
public int Rows { get; set; } = 5;
public int Cols { get; set; } = 5;
}
Inventory.razor.scss:
$grid-color: #FFFFFFf0;
Inventory {
position: absolute;
left: 30%;
top: 20%;
bottom: 20%;
right: 30%;
pointer-events: all;
background-color: #000000F0;
backdrop-filter: blur(10px);
justify-content: center;
align-items: center;
> .outline {
background-color: $grid-color;
padding: 1px;
> .background {
position: relative;
background-color: $grid-color;
flex-direction: column;
gap: 1px;
> .row {
flex-direction: row;
gap: 1px;
}
}
}
}
InventoryCell.razor
@inherits Panel
<root/>
InventoryCell.razor.scss
InventoryCell {
width: 96px;
height: 96px;
background-color: #000000FA;
}
Breaking it down:
outlineserves as the grid border and is kept separate so we don't have to account for a one-pixel offset later.backgroundis the inner part of the grid, made visible through the one-pixelgapbetween cells.- It also has
position: relative, which will matter shortly.
- It also has
- Cells are 96px. Leaving them at 100px would cause small gaps due to rounding — s&box scales the UI relative to 1080p, and rounding errors can accumulate. 96px is safer, though not entirely bulletproof: if you shrink the editor window vertically, you'll still see a crack.
The resulting grid should look something like this:

Displaying Items
The grid looks nice, but it's purely decorative for now, so next up is item rendering. First, we need an item class:
InventoryItem.cs
public class InventoryItem
{
public int Row { get; set; }
public int Col { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public string Icon { get; set; } = "handyman";
}
And a visual wrapper for it:
InventoryItemPanel.razor
@inherits Panel
<root>
<i>@Icon</i>
</root>
@code {
[Parameter]
public InventoryItem Item { get; set; }
[Parameter]
public Inventory Owner { get; set; }
public int Row => Item.Row;
public int Col => Item.Col;
public int Width => Item.Width;
public int Height => Item.Height;
public string Icon => Item.Icon;
public sealed override void Tick()
{
Style.Width = 96 * Width + 1 * (Width - 1);
Style.Height = 96 * Height + 1 * (Height - 1);
SetPosition();
}
protected virtual void SetPosition()
{
Style.Left = Col * 96 + 1 * Col;
Style.Top = Row * 96 + 1 * Row;
}
}
InventoryItemPanel.razor.scss
InventoryItemPanel {
background-color: #000000FA;
color: white;
position: absolute;
font-size: 128px;
justify-content: center;
align-items: center;
}
A few things to note:
- Items use
position: absolute. Sincebackgroundhasposition: relative, items are positioned relative to the grid rather than the entire screen. TickandSetPositiondynamically set the size and position, accounting for the one-pixel gaps between cells.- When setting size, add the gap widths covered by the panel.
- When setting position, count how many gaps come before this cell.
SetPositionis split out fromTickon purpose — we'll come back to that in a moment.
- You'll also notice the magic number
96showing up everywhere — in real code, pull it into a constant.
Now just add item creation and rendering to Inventory.razor:
Inventory.razor
<div class="background">
@* ... *@
@if (item != null)
{
<InventoryItemPanel Item="@item" Owner="@this"/>
}
</div>
@code {
// ...
private InventoryItem item;
protected override void OnStart()
{
item = new InventoryItem
{
Width = 3,
Height = 2,
Row = 1,
Col = 1
};
}
}
The result should look like this:

Drag
Looking good, but it's still a static image, so time to add DnD. Unfortunately, we can't just drag the panel that's inside the grid directly. The reason becomes obvious once you think about how the DOM works — even though this isn't strictly the DOM. An element's appearance is determined not just by itself, but by all surrounding elements, so trying to rearrange it in the hierarchy will most likely end in tears (I tried).
Instead, we create a "ghost" element — a copy attached to a completely different part of the tree, so nothing breaks. Since razor components are just regular C# classes, we don't need to duplicate any logic thanks to inheritance — we can simply extend InventoryItemPanel:
DraggedInventoryItemPanel.cs
[StyleSheet("InventoryItemPanel.razor.scss")]
public class DraggedInventoryItemPanel : InventoryItemPanel
{
[Property]
public DragEvent DragEvent { get; set; }
public override bool WantsDrag => false;
protected override void SetPosition()
{
var mouse = Mouse.Position;
Style.Left = Length.Pixels(mouse.x * ScaleFromScreen - DragEvent.LocalGrabPosition.x * ScaleFromScreen);
Style.Top = Length.Pixels(mouse.y * ScaleFromScreen - DragEvent.LocalGrabPosition.y * ScaleFromScreen);
}
}
A few things worth noting:
SetPositionis exactly why we split it out — we override it here to make the item follow the cursor.- Mouse coordinates include a screen offset, so we multiply by
ScaleFromScreen.
- Mouse coordinates include a screen offset, so we multiply by
DragEventcarries the grab point — so the cursor lands on the ghost element exactly where the drag started.WantsDragis overridden to returnfalse. That's actually its default, but we're about to override it totrueinInventoryItemPanel, so we need to explicitly set it back here.- The
StyleSheetattribute points to the existing style file, so we can reuse it and just add a couple of tweaks.
InventoryItemPanel.razor.scss
InventoryItemPanel, DraggedInventoryItemPanel {
// ...
}
DraggedInventoryItemPanel {
background-color: #000000AA;
border-width: 1px;
border-color: white;
}
Comma-separating the selectors applies shared styles to both elements, and the differences go in a separate block.
Now that we have the ghost element, we need something to own it. We'll base it on the DragHandler from sandbox itself.
DragHandler.razor
@inherits PanelComponent
<root>
@if (Current is not null)
{
<DraggedInventoryItemPanel @ref="_draggedOverlay" class="dragging" Item="@Current.Item" DragEvent="@_currentDragEvent" />
}
</root>
@code
{
public static DragHandler Instance { get; private set; }
public static bool IsDragging => Instance?.Current is not null;
public static Panel DraggedVisual => Instance?._draggedOverlay;
public InventoryItemPanel Current { get; set; }
private DragEvent _currentDragEvent;
private DraggedInventoryItemPanel _draggedOverlay;
protected override void OnStart()
=> Instance = this;
protected override void OnDestroy()
{
Instance = null;
Current = null;
}
public static void StartDragging(InventoryItemPanel data, DragEvent e)
{
if (Instance is null) return;
Instance.Current = data;
Instance._currentDragEvent = e;
Instance.StateHasChanged();
}
public static void StopDragging()
{
if (!IsDragging || Instance is null) return;
Instance._draggedOverlay = null;
Instance.Current = null;
Instance.StateHasChanged();
}
protected override int BuildHash() => HashCode.Combine(Current);
}
DragHandler.razor.scss
DragHandler
{
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 10000;
pointer-events: none;
}
This is a component with static references to itself for convenience, though nothing stops you from using a regular reference instead. The logic could also live inside the inventory itself, but a separate component is cleaner if you want drag and drop between multiple inventories later.
DragHandler properties:
Instance: static reference to the component.IsDragging: whether something is currently being dragged.Current: the source panel of the dragged item.DraggedVisual: the ghost panel itself.
The methods are straightforward and just update those properties. InventoryItemPanel is responsible for calling DragHandler:
InventoryItemPanel.razor
public override bool WantsDrag => true;
protected override void OnDragStart(DragEvent e)
=> DragHandler.StartDragging(this, e);
protected override void OnDragEnd(DragEvent e)
{
DragHandler.StopDragging();
var slot = GetSlot();
if (slot is {} sl)
{
Item.Col = sl.X;
Item.Row = sl.Y;
}
}
private (int X, int Y)? GetSlot()
=> null;
WantsDrag needs to be overridden for the panel to respond to OnDrag callbacks. OnDragStart delegates to DragHandler, and OnDragEnd also handles position updates — though GetSlot is just a stub for now. And yes, slot is {} sl is that quirky C# syntax for a null-check that also gives you a non-null binding right away. Feels a bit like C++ in its roundaboutness.
Either way, we can now drag items around the inventory — even if the stub means we can't actually reposition them yet.

And Drop
All that's left is replacing the stub. It's not complicated:
private (int X, int Y)? GetSlot()
{
var dragged = DragHandler.DraggedVisual;
var x = (int)MathF.Round((dragged.Box.Left - Parent.Box.Left) / 97f);
var y = (int)MathF.Round((dragged.Box.Top - Parent.Box.Top) / 97f);
return x < 0 || y < 0 || x + Width > Owner.Cols || y + Height > Owner.Rows ? null : (x, y);
}
The goal is to find the top-left cell that the dragged panel lands on — that's the coordinate we want to move the item to. We divide the ghost panel's position by the cell size plus gap (hence 97). Parent here is our div with class background.
The final result looks like this:
One more thing worth mentioning. S&box has four more OnDrag methods: OnDrag, OnDragSelect, OnDragEnter, OnDragLeave. I don't know what the first two are for — OnDrag doesn't even have documentation. I did try using the last two, but their behavior wasn't clear to me at all. They'd fire when I wasn't dragging anything, then go silent as soon as something appeared on the cursor. That was my personal "despite it" moment. Maybe if I'd figured them out, the slot detection logic could have been simplified a bit — but it wouldn't have changed the big picture. They'd probably be more useful for inventories where every item is 1x1 anyway.
Wrapping Up
And there we have it — an inventory where you can drag items around. It's far from feature-complete, of course. In roughly descending order of importance:
- No collision logic for multiple items. There are plenty of ways to handle that, and none of them are s&box-specific, so I left it out.
- All the logic is piled in one place.
- As mentioned, magic numbers should be constants.
- Game logic should also be separated from UI — the UI should be a view, not own an actual logic.
- The slot detection is too coarse. You'd want some tolerance so players don't have to land exactly on a cell.
- No highlight showing which cells the item will land on.
But it's a fully functional foundation you can build whatever you want on top of.