Behaviour lives in Java. Appearance lives in YAML. Compiled at boot, hot-reloadable at runtime.
Quick start ยท Features ยท Guide ยท Reload ยท Build
Building inventory GUIs by hand means juggling raw slot indices, cancelling click events, diffing item stacks, and re-wiring everything on Folia. MenuFramework removes that boilerplate:
- ๐ฏ Declarative: annotate a class, drop a YAML file. No
InventoryClickEventplumbing. - โก Reactive: change a
State<?>and the open menu re-renders by diff (only changed slots). - ๐งต Folia-ready: the same code runs on Paper's main thread or a player's region thread.
- ๐ Hot-reload: edit YAML and reload appearance at runtime, sync or async.
- ๐ก๏ธ Safe by default: clicks are cancelled, items can't be stolen, and player-controlled placeholders can't inject MiniMessage tags.
- ๐งฑ Clean architecture: a platform-free core, a thin Paper adapter, a Folia scheduler.
@Menu(id = "main")
public final class MainMenu {
@Button(id = "open-catalog")
public void openCatalog(MenuClick click) {
click.message("<green>Opening catalogโฆ</green>");
}
}title: "<green>Main Menu</green>"
rows: 3
buttons:
open-catalog:
slot: 13
material: CHEST
name: "<green>Open Catalog</green>"That's a complete, clickable menu.
The library is published through JitPack.
menu-paper pulls in menu-core transitively.
repositories {
maven("https://jitpack.io")
}
dependencies {
// Pin a release tag, or use "main-SNAPSHOT" to track the latest commit.
compileOnly("com.github.HanielCota.MenuFramework:menu-paper:v0.2.0")
// Add menu-folia as well when targeting Folia:
// compileOnly("com.github.HanielCota.MenuFramework:menu-folia:v0.2.0")
}MenuFramework framework =
MenuFramework.builder(this)
.scan("com.example.plugin.menu")
.build();Tear it down in onDisable so listeners, tick tasks and open menus are cleaned up:
@Override
public void onDisable() {
framework.shutdown();
}framework.open(player, new MenuId("main"));YAML files live in plugins/<PluginName>/menus/<id>.yml.
๐ค Using an AI assistant? Point it at
AGENTS.md, a complete and verified API reference written for code-generating tools, anddocs/menu.schema.jsonfor YAML validation and editor autocomplete.llms.txtindexes both.
| Feature | What it does | |
|---|---|---|
| ๐งพ | Static menus | @Menu + @Button classes, appearance in YAML. |
| ๐ | Pagination | @Paginated provider sliced into pages with a mask layout. |
| โ๏ธ | Reactive state | @Reactive State<?> drives coalesced, diff-based re-renders. |
| โฑ๏ธ | Auto-update | @Tick runs on a schedule for countdowns & animations. |
| ๐ช | Lifecycle hooks | @OnOpen / @OnClose run as the view opens and closes. |
| ๐งฎ | Placeholders | Per-viewer PlaceholderAPI tokens (soft dependency). |
| ๐ | Rich items | Amount, glow, unbreakable, model data, tooltip flags. |
| ๐ | Permissions & cooldowns | Gate opens and clicks; rate-limit per player. |
| ๐ | Hot-reload | Reload YAML at runtime (sync, reported, or async). |
| ๐งต | Paper & Folia | One API, correct thread on each platform. |
๐ Paginated & reactive menus
@Menu(id = "catalog")
public final class CatalogMenu {
@Reactive private final State<Category> category = State.of(Category.TOOLS);
@Button(id = "next-category")
public void nextCategory() {
category.set(category.get().next()); // re-renders the open view
}
@Paginated
public List<MenuItem> products() {
return products.in(category.get()).stream().map(this::item).toList();
}
}The pagination mask maps characters to slots:
| Symbol | Meaning |
|---|---|
X |
content slot |
# |
border slot |
< |
previous page |
> |
next page |
| (space) | empty slot |
title: "<gold>Catalog</gold>"
rows: 6
pagination:
mask:
- "#########"
- "#XXXXXXX#"
- "#XXXXXXX#"
- "#XXXXXXX#"
- "#XXXXXXX#"
- "#<#####>#"
previous-button: { material: ARROW, name: "<yellow>Previous</yellow>" }
next-button: { material: ARROW, name: "<yellow>Next</yellow>" }โฑ๏ธ Auto-updating menus (countdowns & animations)
A @Tick method runs on a fixed schedule while the menu is open, on the view's owning thread, so it
may update a @Reactive State<?>, which drives the usual coalesced, diff-based re-render. The tick
starts on open and is cancelled on close (no leaked task).
@Menu(id = "event")
public final class EventMenu {
@Reactive private final State<Integer> secondsLeft = State.of(300);
@Tick(period = 20) // 20 ticks = 1 second
public void countdown() {
secondsLeft.set(Math.max(0, secondsLeft.get() - 1));
}
@Paginated
public List<MenuItem> items() {
return List.of(
MenuItem.of(
Icons.of(Material.CLOCK).named("<yellow>Starts in " + secondsLeft.get() + "s</yellow>")));
}
}๐งฎ Placeholders (PlaceholderAPI)
Paginated menus resolve %placeholders% per viewer in the page content and the title, as a
PlaceholderAPI soft dependency: installed, the tokens are filled for each player; absent, the text is
left as-is. Resolution runs before MiniMessage parsing and only for the open view, so each player
sees their own values without cross-player cache bleed.
title: "<gold>%player_name%'s Bag</gold>"@Paginated
public List<MenuItem> items() {
return List.of(
MenuItem.of(Icons.of(Material.GOLD_INGOT).named("<yellow>Balance: %vault_eco_balance%</yellow>")));
}Values that change over time refresh on the next re-render. Pair them with @Tick for a live value.
๐ก๏ธ Security: placeholder values are MiniMessage-escaped before parsing, so a player whose name or nickname contains tags like
<click>or<hover>cannot inject live components into another viewer's menu. The author's own template tags are still parsed.
๐ช Lifecycle hooks
@OnOpen and @OnClose methods on a paginated menu run when the view opens and closes, on the
view's owning thread. Each takes no arguments or a single Player. Reactive state and ticks are torn
down for you before @OnClose runs.
@Menu(id = "shop")
public final class ShopMenu {
@OnOpen
public void onOpen(Player player) {
player.playSound(player, Sound.BLOCK_CHEST_OPEN, 1f, 1f);
}
@OnClose
public void onClose(Player player) {
player.playSound(player, Sound.BLOCK_CHEST_CLOSE, 1f, 1f);
}
@Paginated
public List<MenuItem> items() {
return List.of();
}
}๐ Rich items
Buttons and code-built icons carry an appearance beyond material, name and lore: stack amount, an
enchantment glow, unbreakable, custom modelData and tooltip flags.
In YAML:
buttons:
legendary:
slot: 13
material: NETHERITE_SWORD
name: "<gold>Legendary Blade</gold>"
amount: 1
glow: true
unbreakable: true
model-data: 1001
flags: [HIDE_ATTRIBUTES, HIDE_UNBREAKABLE]In code (fluent and immutable):
Icon icon = Icons.of(Material.DIAMOND)
.named("<aqua>Gem</aqua>")
.amount(16)
.glowing()
.hiding(ItemFlag.HIDE_ATTRIBUTES);๐ Permissions & cooldowns
Restrict who can open a menu or click a button. A click by a player lacking the permission is
silently ignored; open does nothing for a player lacking the menu permission.
@Menu(id = "vault", permission = "myplugin.vault.open")
public final class VaultMenu {
@Button(id = "withdraw", permission = "myplugin.vault.withdraw")
public void withdraw(MenuClick click) {
click.message("<green>Withdrawn.</green>");
}
}Rate-limit a button per player with cooldownMillis; a click made while cooling down is silently
dropped (permission is checked first, so a denied click never starts the cooldown). The window is
per player and survives reopening the menu.
@Button(id = "daily", cooldownMillis = 3000)
public void claimDaily(MenuClick click) {
click.message("<green>Claimed.</green>");
}๐งฐ Bootstrapping with dependencies
Use a custom instantiator when menus have constructor dependencies (e.g. a DI container):
MenuFramework.builder(this)
.instantiator(type -> container.create(type))
.scan("com.example.plugin.menu")
.build();// Synchronous
boolean reloaded = framework.reload(new MenuId("main"));
int count = framework.reloadAll();
// Reported
ReloadReport report = framework.reloadAllReport();
// Async YAML IO
framework.reloadAllReportAsync()
.thenAccept(r -> sender.sendMessage(Component.text(r.successCount() + " reloaded")));Only YAML IO and parsing run asynchronously. Menu compilation and ItemStack creation run on the
plugin scheduler, since Bukkit inventory APIs are not thread-safe. Reloads are cached by file
metadata plus a CRC32 content checksum, so unchanged files are not parsed again.
Validation (early, with a clear message rather than a stack trace):
rowsmust be between1and6- button slots must fit the menu size
- pagination masks must match the menu rows, be 9 columns wide, and contain at least one
X - material names are validated against Bukkit's
Materialregistry
Requires a JDK 25 toolchain (auto-provisioned via the Foojay resolver on CI/JitPack).
./gradlew clean test build.\gradlew.bat clean test buildThe example plugin shadow jar is produced at:
example-plugin/build/libs/example-plugin-0.2.0.jar
CI runs the full suite and shaded build on Ubuntu and Windows with Java 25.
| Module | Responsibility |
|---|---|
menu-core |
Platform-free annotations, config, compiler, state and merge logic. |
menu-paper |
Paper-facing facade, listener, rendering and registry. |
menu-folia |
Folia scheduler implementation. |
example-plugin |
Runnable example plugin using the public API. |
Target stack: Java 25 ยท Paper API 26.1.2 (Minecraft 1.21+) ยท Adventure / MiniMessage ยท Gradle.
Released under the MIT License ยฉ 2026 Haniel Fialho.