Pagination
Introduction
Section titled “Introduction”Lists get long. Whether it’s a moderation log, a list of warnings, or a server’s banned users, you’ll eventually have more data than fits in a single Discord embed. Scrolling through a wall of text is no fun for anyone.
The Paginator class gives you a clean, fluent API to split any list into pages with built-in navigation buttons. You give it your data, tell it how to render each page, and it handles all the button interactions, page transitions, timeouts, and cleanup automatically.
Basic usage
Section titled “Basic usage”At its core, the Paginator needs three things: the items to paginate, how many items per page, and a render function that turns a page of items into an embed:
import {command, Command, CommandInteraction, Params, Paginator} from '@warden/core';import {Embed} from '@warden/core';
@command({ name: 'warnings', description: 'View warning history for a member' })export default class WarningsCommand implements Command { public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const warnings = [ /* ...array of warning records... */ ];
new Paginator(interaction) .items(warnings) .pageSize(5) .render((page, pageNumber, totalPages) => { return Embed.info(page.map((w) => `**#${w.id}** - ${w.reason}`).join('\n')) .setTitle('Warning History') .setFooter({ text: `Page ${pageNumber} of ${totalPages}` }); }) .send(); }}That’s it. The user sees a nicely formatted embed with navigation buttons. No manual collector setup, no button ID wiring, no timeout handling.
The fluent API
Section titled “The fluent API”The Paginator uses a chainable, fluent interface. Here’s every method you can call:
| Method | Description |
|---|---|
.items(array) | The full array of items to paginate |
.pageSize(number) | How many items per page (default: 10) |
.render(fn) | A function that receives the current page’s items and returns an embed |
.ephemeral() | Send the paginated message as ephemeral (only visible to the user) |
.send() | Build everything and send the first page |
Every method returns the Paginator instance, so you can chain them in any order before calling .send():
new Paginator(interaction) .items(records) .pageSize(10) .ephemeral() .render((page, pageNumber, totalPages) => { // ... }) .send();Rendering pages
Section titled “Rendering pages”The .render() callback receives three arguments:
| Argument | Type | Description |
|---|---|---|
page | T[] | The slice of items for the current page |
pageNumber | number | The current page number (1-indexed) |
totalPages | number | The total number of pages |
Your render function should return an EmbedBuilder (or one of the Embed.* presets, which return an EmbedBuilder). You have full control over what the embed looks like — title, description, fields, colors, footers, thumbnails — it’s all up to you:
.render((page, pageNumber, totalPages) => { const lines = page.map((entry, i) => { const index = (pageNumber - 1) * 10 + i + 1; return `\`${index}.\` ${entry.user.tag} - ${entry.reason}`; });
return Embed.info(lines.join('\n')) .setTitle('Moderation Log') .setFooter({ text: `Page ${pageNumber}/${totalPages}` });})Navigation buttons
Section titled “Navigation buttons”The Paginator automatically adds navigation buttons below the embed:
| Button | Behavior |
|---|---|
| First | Jump to the first page |
| Previous | Go back one page |
| Next | Go forward one page |
| Last | Jump to the last page |
Buttons that would have no effect are automatically disabled. For example, on the first page, the “First” and “Previous” buttons are grayed out. On the last page, “Next” and “Last” are grayed out.
If your data only has one page, no buttons are shown at all — because there’s nothing to navigate.
Ephemeral pagination
Section titled “Ephemeral pagination”Sometimes a paginated list should only be visible to the person who requested it — like when a moderator is reviewing their own action history, or when a user checks their own warnings. Just chain .ephemeral():
new Paginator(interaction) .items(myWarnings) .pageSize(5) .ephemeral() .render((page, pageNumber, totalPages) => { return Embed.info(page.map((w) => `${w.date} - ${w.reason}`).join('\n')) .setTitle('Your Warnings') .setFooter({ text: `Page ${pageNumber} of ${totalPages}` }); }) .send();The entire paginated interaction — initial message and all subsequent page transitions — stays ephemeral.
Collector timeout and cleanup
Section titled “Collector timeout and cleanup”The Paginator uses a component collector under the hood to listen for button clicks. By default, it times out after a reasonable period of inactivity. When the collector expires:
- The navigation buttons are removed from the message (or disabled, depending on whether the message is ephemeral)
- The embed content stays visible so the user can still read whatever page they were on
- All internal listeners are cleaned up — no memory leaks, no dangling collectors
You don’t need to manage any of this yourself. The Paginator handles the full lifecycle from first render to final cleanup.
Practical example: moderation case history
Section titled “Practical example: moderation case history”Here’s a complete, real-world example — a /cases command that lets moderators browse through all moderation actions taken against a specific user:
import {command, Command, permissions, CommandInteraction, Params, Paginator, Embed, reply} from '@warden/core';import {Embed} from '@warden/core';
@command({ name: 'cases', description: 'View moderation cases for a member' })@permissions({ user: ['ModerateMembers'] })export default class CasesCommand implements Command { public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const target = interaction.options.getUser('user')!;
// Fetch all cases from your database const cases = database.getCases(target.id);
if (cases.length === 0) { await reply(interaction, Embed.info(`No moderation cases found for ${target.tag}.`)); return; }
new Paginator(interaction) .items(cases) .pageSize(5) .render((page, pageNumber, totalPages) => { const lines = page.map((c) => { const emoji = { warn: '\u26a0\ufe0f', mute: '\ud83d\udd07', kick: '\ud83d\udc62', ban: '\ud83d\udd28' }[c.type]; return [ `${emoji} **Case #${c.id}** - ${c.type.toUpperCase()}`, `Moderator: <@${c.moderatorId}>`, `Reason: ${c.reason}`, `Date: ${c.createdAt.toLocaleDateString()}`, '', ].join('\n'); });
return Embed.info(lines.join('\n')) .setTitle(`Moderation Cases: ${target.tag}`) .setFooter({ text: `Page ${pageNumber} of ${totalPages} | ${cases.length} total cases` }); }) .send(); }}The moderator sees a clean, paginated embed they can browse through. Five cases per page, clear formatting, and navigation buttons that just work. When they’re done reviewing, the buttons quietly clean themselves up.