Open chest-style menus with clickable item slots using Basalt's @basalt/menus module.
@basalt/menus opens container windows, such as a chest, that have no real block behind them. Each slot holds an item you choose, and your plugin hears every click. They are the usual way to build shops, kit selectors, navigators and any menu where item icons work better than a list of buttons.
This module is Basalt’s own. It does not exist on BDS, so a plugin that imports it only runs on Basalt.
import { Menu, ChestMenu, MenuType, MenuClickAction } from "@basalt/menus";You do not need to list @basalt/menus in your manifest’s dependencies.
A first menu
Section titled “A first menu”import { world, ItemStack } from "@minecraft/server";import { Menu, MenuType } from "@basalt/menus";
const menu = new Menu(MenuType.Chest, "Navigator");
const spawn = new ItemStack("minecraft:compass", 1);spawn.nameTag = "§aSpawn";spawn.setLore(["§7Click to teleport"]);
menu.setItem(13, spawn, (event) => { event.cancel = true; event.player.teleport({ x: 0, y: 80, z: 0 }); menu.close(event.player);});
// Refuse every other click, so nothing can be taken or put in.menu.onClick((event) => { event.cancel = true;});
world.afterEvents.itemUse.subscribe(({ source, itemStack }) => { if (itemStack.typeId === "minecraft:clock") void menu.show(source);});Three things are happening:
setItem(slot, itemStack, onClick)puts an item in a slot and, optionally, gives that slot its own click handler.onClickhandles clicks on every slot that has no handler of its own.show(player)opens the window.
Menu types
Section titled “Menu types”new Menu(type, title) takes a MenuType. new ChestMenu(title, { large: true }) is a shortcut for a chest or a double chest.
MenuType |
Slots |
|---|---|
Chest, EnderChest, Barrel, ShulkerBox |
27 |
DoubleChest |
54 |
Hopper |
5 |
Dispenser, Dropper |
9 |
Furnace, BlastFurnace, Smoker |
3: input, fuel, output |
BrewingStand |
5 |
CraftingTable |
9 |
Anvil, EnchantingTable, Grindstone, CartographyTable |
2 |
Loom, SmithingTable |
3 |
Stonecutter, Beacon |
1 |
Slots are numbered from 0, left to right and top to bottom. In a chest, slot 13 is the middle of the middle row.
The workstation types (crafting table, anvil and the rest) show their slots but do not do anything: nothing crafts, repairs or enchants. They are there for their look. Some of them show their own title instead of yours.
Clicks
Section titled “Clicks”A click handler receives a MenuClickEvent:
| Field | Meaning |
|---|---|
menu |
The menu that was clicked |
player |
Who clicked |
slot |
The slot number |
action |
What the player did: a MenuClickAction |
itemStack |
What the slot holds now |
result |
What the slot will hold if the click goes ahead |
cancel |
Set to true to refuse the click |
MenuClickAction |
The player |
|---|---|
Take |
Picked the item up |
Place |
Put an item down |
Swap |
Swapped it with the item on their cursor |
Drop |
Threw it out of the window |
Shift |
Shift-clicked it to move it across |
A refused click changes nothing; the player’s client puts everything back.
Click handlers are before events
Section titled “Click handlers are before events”Basalt waits for the handler’s answer, for at most 50 ms. If the plugin does not answer in time, the click is refused. The rule about reads inside before events applies: the event’s own fields are always there, and changes such as teleport or addItem are fine, but reading the player’s inventory or location belongs in system.run.
A shop
Section titled “A shop”This shop sells items for emeralds. The click handler only decides which offer was clicked; counting and taking the emeralds reads the inventory, so it is deferred to system.run.
import { system, world, ItemStack, Player } from "@minecraft/server";import { Menu, MenuType, MenuClickAction } from "@basalt/menus";
const offers = new Map([ [11, { typeId: "minecraft:bread", cost: 1 }], [13, { typeId: "minecraft:diamond", cost: 10 }], [15, { typeId: "minecraft:iron_sword", cost: 5 }],]);
const shop = new Menu(MenuType.Chest, "Shop");for (const [slot, { typeId, cost }] of offers) { const icon = new ItemStack(typeId, 1); icon.setLore([`§a${cost} emeralds`]); shop.setItem(slot, icon);}
/** Takes `cost` emeralds from the player. False, taking nothing, if they have too few. */function pay(player: Player, cost: number): boolean { const container = player.getComponent("minecraft:inventory")?.container; if (!container) return false;
const slots: number[] = []; let found = 0; for (let slot = 0; slot < container.size; slot++) { const item = container.getItem(slot); if (item?.typeId !== "minecraft:emerald") continue; slots.push(slot); found += item.amount; } if (found < cost) return false;
let left = cost; for (const slot of slots) { const item = container.getItem(slot); if (!item || left === 0) continue; if (item.amount <= left) { left -= item.amount; container.setItem(slot, undefined); } else { item.amount -= left; left = 0; container.setItem(slot, item); } } return true;}
shop.onClick((event) => { event.cancel = true; // nothing leaves the shop by hand const offer = offers.get(event.slot); if (!offer || event.action !== MenuClickAction.Take) return;
const { player } = event; system.run(() => { if (!player.isValid) return; if (!pay(player, offer.cost)) { player.sendMessage("§cYou cannot afford that."); return; } player.getComponent("minecraft:inventory")?.container.addItem(new ItemStack(offer.typeId, 1)); player.playSound("random.orb"); });});
world.afterEvents.chatSend.subscribe(async ({ sender, message }) => { if (message !== "!shop") return; await shop.show(sender); sender.sendMessage("Come again!");});Opening and closing
Section titled “Opening and closing”show(player) opens the menu, closing any window the player already has open. It returns a promise that resolves when that player’s window closes, whatever closed it:
- the player closed it,
- the plugin called
menu.close(player), ormenu.close()for everyone, - the player died, teleported or left.
const closed = await menu.show(player);console.log(`${closed.player.name} closed ${closed.menu.title}`);onClose(callback) runs for every close, which is handy when a menu is shown from several places.
As with forms, do not call show directly inside a before event or command callback. Start it from system.run.
Shared and per-player menus
Section titled “Shared and per-player menus”One Menu object can be open for many players at once. They all see the same slots: when you call setItem while it is open, the slot changes in front of everyone, and a click that goes ahead changes it for the others too. That suits a shared shop or a live display.
For a menu whose contents depend on the player, build a new Menu each time:
import { ItemStack, Player } from "@minecraft/server";import { ChestMenu } from "@basalt/menus";
function openProfile(player: Player): void { const menu = new ChestMenu(`${player.name}'s profile`);
const head = new ItemStack("minecraft:paper", 1); head.nameTag = `§e${player.name}`; head.setLore([`§7Level ${player.level}`]); menu.setItem(13, head);
menu.onClick((event) => { event.cancel = true; }); void menu.show(player);}What happens to the items
Section titled “What happens to the items”The slots belong to the menu, not to any player or block:
- Items stay in the menu when the window closes. Nothing drops into the world.
- Nothing is lost if the player dies or leaves with the menu open.
- A menu’s contents live in memory. They are gone when the plugin reloads or Basalt stops, so save anything that matters yourself, for example in dynamic properties.
Other members:
| Member | Does |
|---|---|
getItem(slot) |
Returns what a slot holds |
clear() |
Empties every slot |
size |
The number of slots |
type |
The MenuType |
title |
The window title. Changing it affects players who open the menu afterwards. |
setItem and getItem throw a RangeError for a slot number the menu does not have.
How it works
Section titled “How it works”To open a container window, the client needs a container block to attach it to. Basalt sends the player a block that only they can see, just above their head, opens the window on it, and restores the real block when the window closes. Other players never see it, and nothing in the world changes.
The window opens a tick or two after show is called.
The full list of classes is in @basalt/menus.