Pagination
Pagination
Pagination is what you use when your menu has more items than one inventory can show at once. Instead of cramming everything into one screen, Lotus splits the content into multiple pages and adds navigation for you.
If this is your first GUI framework, think about pagination like this:
- you describe what one paginated menu should look like
- Lotus opens that menu for a player
- Lotus keeps track of which page that player is currently viewing
Lotus splits pagination into a shared definition and a live player session.
Pagination<T>is the reusable recipePaginationSession<T>is one player's active run through that recipe
Build the definition once, then open it for as many players as you want.
pagination.open(lotus, player) creates a new session, goes to page 0, and opens the menu
immediately.
Page indexes are zero-based. That means:
- the first page is index
0 - the second page is index
1 - the third page is index
2
That is why the examples use ctx.pageIndex() + 1 when showing the page number in the menu title.
Players should see 1, 2, 3, while your code works with 0, 1, 2.
The Three Pieces
1. PageLayout
The layout defines:
- page capacity
- title
- fill area for page items
- previous/next button slots
- decorative content
Capacity capacity = Capacity.ofRows(6);
PaperPageLayout}</CodeTabItem> <CodeTabItem value="spigot" label="Spigot 1.8.8" language="java">{
Capacity capacity = Capacity.ofRows(6);
SpigotPageLayout
2. ContentSource<T>
The source provides the full list of things you want to show.
For example, in a tags menu, each item in the source could represent one available tag. Lotus then splits that list across pages automatically.
There are two common ways to create a content source:
ContentSource<TagEntry> fixed = ContentSource.of(entries);
ContentSource<TagEntry> dynamic = ContentSource.dynamic(player -> tagService.entriesFor(player));
ContentSource.of(...) vs ContentSource.dynamic(...)
This is one of the most important pagination choices, because it decides where the page data comes from.
| Option | Best for | What it does |
|---|---|---|
ContentSource.of(entries) |
Same content for everyone | Reuses one fixed list for every player |
ContentSource.dynamic(player -> ...) |
Per-player content | Builds the list from the current player |
Use ContentSource.of(...) when the list is shared
Use of(...) when every player should see the same items.
Examples:
- a global tags list
- a static list of menu actions
- a help menu with the same entries for everyone
List<TagEntry> entries = List.of(
new TagEntry("Builder", builderIcon),
new TagEntry("Champion", championIcon)
);
ContentSource<TagEntry> source = ContentSource.of(entries);
With of(...), Lotus stores a fixed copy of the list and uses that same list for all viewers.
Use ContentSource.dynamic(...) when the list depends on the player
Use dynamic(...) when the items should change depending on who opened the pagination.
Examples:
- a friends list
- a personal mailbox
- a tags list filtered by the player's rank, world, or progress
ContentSource<FriendEntry> source = ContentSource.dynamic(
player -> friendService.friendsOf(player.getUniqueId())
);
With dynamic(...), Lotus calls your function with the current player and expects back the list
that should be shown to that player.
ContentSource.dynamic(...) is called once when the session is created, not once per page.
If the backing data changes while a player is browsing, refresh or reopen the session.
dynamic(...) does not mean "live-updating every second".
It means "build the list dynamically for this player when the pagination session starts". After that, the session works with its current snapshot until you reload it or reopen it.
Simple mental model
of(...): "everyone gets this one prepared list"dynamic(...): "build a list for the player who just opened the menu"
Which one should beginners pick?
- Pick
of(...)if you already have one list and it should be the same for every player. - Pick
dynamic(...)if your list needs thePlayerto decide what should appear.
If you are unsure, start with of(...). It is simpler. Move to dynamic(...) when you actually
need player-specific results.
3. ItemRenderer<T, X>
The renderer tells Lotus how to turn one item from your source into one clickable button in the inventory:
Renamed from
ComponentRendererin 2.1.0 — the old name suggested Adventure text formatting but it produces aButton. The deprecated alias still works; remove it before 3.0.0.
ItemRenderer<TagEntry, ?> renderer = (entry, ctx) -> Button.clickable(
entry.icon(),
(view, event) -> tagService.select(view.viewer(), entry.id())
);
A Complete Definition
Pagination
pagination.open(lotus, player);
}</CodeTabItem> <CodeTabItem value="spigot" label="Spigot 1.8.8" language="java">{
record TagEntry(String id, ItemStack icon) {}
Pagination
pagination.open(lotus, player); `}
Full End-to-End Example
This example shows the whole flow in one place:
- create the Lotus runtime
- build one shared pagination definition
- load player-specific entries
- open the pagination from a command
- refresh already-open views after data changes
public final class TagsPlugin extends JavaPlugin {
private Lotus<Component> lotus;
private TagService tagService;
private Pagination<TagEntry> tagPagination;
@Override
public void onEnable() {
this.lotus = PaperLotus.create(this);
this.tagService = new TagService();
this.tagPagination = createTagsPagination();
getCommand("tags").setExecutor((sender, command, label, args) -> {
if (!(sender instanceof Player player)) {
return true;
}
tagPagination.open(lotus, player);
return true;
});
}
private Pagination<TagEntry> createTagsPagination() {
Capacity capacity = Capacity.ofRows(6);
PaperPageLayout<TagEntry> layout = PaperPageLayout.<TagEntry>builder(capacity)
.title(ctx -> Component.text(
"Tags " + (ctx.pageIndex() + 1) + "/" + ctx.totalPages(),
NamedTextColor.GOLD
))
.fillMask(SlotMask.full(capacity).excluding(
Slot.at(5, 3, capacity),
Slot.at(5, 5, capacity)
))
.previousButton(Slot.at(5, 3, capacity), ctx -> Button.of(
ItemBuilder.of(Material.ARROW)
.displayName(Component.text("Previous Page", NamedTextColor.YELLOW))
.build()
))
.nextButton(Slot.at(5, 5, capacity), ctx -> Button.of(
ItemBuilder.of(Material.ARROW)
.displayName(Component.text("Next Page", NamedTextColor.YELLOW))
.build()
))
.build();
return Pagination.<TagEntry>builder("tags")
.layout(layout)
.source(ContentSource.dynamic(player -> tagService.entriesFor(player)))
.renderer((entry, ctx) -> Button.clickable(
entry.icon(),
(view, event) -> {
tagService.select(view.viewer(), entry.id());
PaperLotus.syncOpenPagination(lotus, "tags", view.viewer());
}
))
.build();
}
public void refreshTagsForAllPlayers() {
PaperLotus.syncOpenPagination(lotus, "tags");
}
public record TagEntry(String id, ItemStack icon) {}
public static final class TagService {
public List<TagEntry> entriesFor(Player player) {
return List.of(
new TagEntry("builder", ItemBuilder.of(Material.NAME_TAG)
.displayName(Component.text("Builder", NamedTextColor.AQUA))
.lore(Component.text("Shown for creative players", NamedTextColor.GRAY))
.build()),
new TagEntry("champion", ItemBuilder.of(Material.NAME_TAG)
.displayName(Component.text("Champion", NamedTextColor.GOLD))
.lore(Component.text("Shown for ranked players", NamedTextColor.GRAY))
.build())
);
}
public void select(Player player, String tagId) {
player.sendMessage(Component.text("Selected tag: " + tagId, NamedTextColor.GREEN));
}
}
}
}</CodeTabItem> <CodeTabItem value="spigot" label="Spigot 1.8.8" language="java">{
public final class TagsPlugin extends JavaPlugin {
private Lotus<String> lotus;
private TagService tagService;
private Pagination<TagEntry> tagPagination;
@Override
public void onEnable() {
this.lotus = SpigotLotus.create(this);
this.tagService = new TagService();
this.tagPagination = createTagsPagination();
getCommand("tags").setExecutor((sender, command, label, args) -> {
if (!(sender instanceof Player player)) {
return true;
}
tagPagination.open(lotus, player);
return true;
});
}
private Pagination<TagEntry> createTagsPagination() {
Capacity capacity = Capacity.ofRows(6);
SpigotPageLayout<TagEntry> layout = SpigotPageLayout.<TagEntry>builder(capacity)
.title(ctx -> "&6Tags " + (ctx.pageIndex() + 1) + "/" + ctx.totalPages())
.fillMask(SlotMask.full(capacity).excluding(
Slot.at(5, 3, capacity),
Slot.at(5, 5, capacity)
))
.previousButton(Slot.at(5, 3, capacity), ctx -> Button.of(
ItemBuilder.of(Material.ARROW)
.displayName("&ePrevious Page")
.build()
))
.nextButton(Slot.at(5, 5, capacity), ctx -> Button.of(
ItemBuilder.of(Material.ARROW)
.displayName("&eNext Page")
.build()
))
.build();
return Pagination.<TagEntry>builder("tags")
.layout(layout)
.source(ContentSource.dynamic(player -> tagService.entriesFor(player)))
.renderer((entry, ctx) -> Button.clickable(
entry.icon(),
(view, event) -> {
tagService.select(view.viewer(), entry.id());
SpigotLotus.syncOpenPagination(lotus, "tags", view.viewer());
}
))
.build();
}
public void refreshTagsForAllPlayers() {
SpigotLotus.syncOpenPagination(lotus, "tags");
}
public record TagEntry(String id, ItemStack icon) {}
public static final class TagService {
public List<TagEntry> entriesFor(Player player) {
return List.of(
new TagEntry("builder", ItemBuilder.of(Material.NAME_TAG)
.displayName("&bBuilder")
.lore("&7Shown for creative players")
.build()),
new TagEntry("champion", ItemBuilder.of(Material.NAME_TAG)
.displayName("&6Champion")
.lore("&7Shown for ranked players")
.build())
);
}
public void select(Player player, String tagId) {
player.sendMessage("Selected tag: " + tagId);
}
}
} `}
The Session API
When you open a pagination, Lotus returns a session.
The session is the object that remembers things like:
- which player opened the pagination
- which page they are currently on
- how many total pages currently exist
Opening a pagination returns that live session:
PaginationSession<?, ?, ?> session = pagination.open(lotus, player);
session.next();
session.previous();
session.goTo(2);
session.reload();
session.close();
Useful reads:
session.currentIndex();
session.totalPages();
session.isFirst();
session.isLast();
session.viewer();
session.definition();
session.goTo(0) means "go to the first page", not "go to page one".
If you want to show page numbers to players, usually display currentIndex() + 1.
Refreshing Already Open Pagination Views
There are two ways to refresh pagination UIs that players already have open.
1. If you already hold the session
Call reload():
session.reload();
That rebuilds the session snapshot, recalculates page count, and reopens the current page.
2. If you only know the pagination definition ID
Each pagination definition has an ID, which is the string you pass to Pagination.builder("...").
If you want to refresh open views by that ID, use the platform helper.
The first overload checks one player's currently open view.
The second overload checks all open views managed by that Lotus<Component> instance.
The first overload checks one player's currently open view.
The second overload checks all open views managed by that Lotus<String> instance.
These helpers only reload views whose currently displayed pagination definition ID matches the given value, so they are safe to call even when other menus are open.
What PageContext Gives You
Every layout and renderer callback receives a page context with:
pageIndex()totalPages()viewer()session()isFirst()isLast()
That lets you:
- show
Page 3/8in the title - hide or change edge navigation buttons
- reach back into the session when needed
The most common one for beginners is pageIndex().
Remember: pageIndex() starts at 0, so the first page is 0.
For pagination fill order, prefer masks like SlotMask.full(...).excluding(...) or
SlotMask.range(...).excluding(...). They make the item flow predictable and easy to reason about.
A Good Reuse Pattern
Pagination definitions are immutable, so they are good candidates for shared constants:
public final class TagPages {
public static final Pagination<TagEntry> TAGS = build();
private static Pagination<TagEntry> build() {
return Pagination.<TagEntry>builder("tags")
.layout(layout)
.source(source)
.renderer(renderer)
.build();
}
}
Next: Advanced for sync helpers, custom openers, and deeper extension points.