How to paginate Discord embeds with buttons using custom commands bot
Learn how to split a Discord message into pages your members can click through, using the paginator built into custom commands bot.
Md Shahriyar Alam
15 minutes ago
One Discord message, several pages, buttons to move between them.
You add the pages. The paginator draws the buttons, tracks which page is showing, greys out Prev on the first page and Next on the last, and closes itself when nobody is clicking.
A page is whatever you want on screen — text, an embed, a file. It does not have to be a list of anything.
It also returns immediately when you start it. Your custom command finishes, your post hook runs, and any lock you took is released. The older paginator sat in a loop waiting for clicks, so nothing after it ran until the pages timed out.
Everything below goes in the code box of a slash command or a text command.
Quickstart
p = PaginatorV2(interaction=interaction)
p.add_page("First page")
p.add_page("Second page")
p.add_page("Third page")
await p.start()Three buttons appear: Prev, Next, Stop. Only the person who ran the command can use them. After three minutes without a click, the session ends and the buttons grey out.
Pages
add_page() accepts three shapes. Use whichever suits the page.
A plain string
p.add_page("Just some text")A Message
The same builder you use everywhere else in a script.
p.add_page(Message(
title="Server rules",
description="Be kind.",
color="#5865F2",
footer="Page %current_page% of %total_page%",
))A Page
Reach for this when you need content and an embed on the same page, or when the page carries a file.
p.add_page(Page(
content="Here is the chart:",
embed=Message(title="October"),
attachments=[Attachment(file=chart_bytes, filename="chart.png")],
))Every field is optional, but a page must carry at least one of them. An empty page raises an error rather than sending a blank message.
Building pages in a loop
members = await get_members()
per_page = 10
for start in range(0, len(members), per_page):
chunk = members[start:start + per_page]
lines = "\n".join(f"• {m.mention}" for m in chunk)
p.add_page(Message(title="Members", description=lines))A paginator holds at most 200 pages.
Placeholders
Two placeholders are filled in as each page is drawn:
| Placeholder | Becomes |
|---|---|
%current_page% | the page number being viewed, starting at 1 |
%total_page% | how many pages there are |
They work in a page's content, and in an embed's title, description and footer.
p.add_page(Message(
description="Some text",
footer="Page %current_page% of %total_page%",
))Your stored page keeps its placeholders, so revisiting a page renders it correctly again. There is no counter button in the middle of the row — put the count in a footer where it belongs.
The built-in buttons
Prev and Next are always shown; a paginator without them is not a paginator. The rest you switch on.
p = PaginatorV2(
interaction=interaction,
show_first=True, # jump to page 1
show_last=True, # jump to the final page
show_jump=True, # pick a page directly
show_stop=True, # close the paginator (on by default)
)They always appear in reading order: First, Prev, Next, Last, Stop.
First and Prev grey out on page 1. Next and Last grey out on the final page. You do not manage that.
Renaming them
p = PaginatorV2(
interaction=interaction,
prev_text="Back",
next_text="Forward",
stop_text="Close",
first_text="Start",
last_text="End",
jump_text="Go to",
)About Jump
show_jump=True only appears when there are more than 2 pages — below that
Prev and Next already reach everything.
- 25 pages or fewer → a dropdown menu listing every page, on its own row.
- More than 25 → a Jump button that opens a small box to type a page number into. Discord menus cannot hold more than 25 options, so past that a text box is the only honest option.
Both are handled for you. You do not write anything extra.
Emojis
The easy way
Upload the art in paginator-emojis/ as application emojis — they belong
to the bot, work in every server, and use none of a server's emoji slots:
.venv/bin/python scripts/upload_paginator_emojis.py --apply
Name them exactly:
| Button | Emoji name |
|---|---|
| First | pg_first |
| Prev | pg_prev |
| Next | pg_next |
| Last | pg_last |
| Stop | pg_close |
| Jump | pg_jump |
That is the entire setup. Paginators find them by name — there are no ids to copy anywhere. Uploading is a one-time job.
To turn the lookup off:
p = PaginatorV2(interaction=interaction, auto_emojis=False)Your own emojis
p = PaginatorV2(
interaction=interaction,
prev_emoji="⬅️",
next_emoji="➡️",
stop_emoji="<:my_close:1234567890>",
)Unicode emojis work as-is. A custom emoji must be the full
<:name:id> form — the bare name or the id alone will be rejected by
Discord as an invalid emoji.
Anything you set by hand wins over the automatic lookup, so you can replace one button and leave the others alone.
Emoji-only buttons
p = PaginatorV2(interaction=interaction, emoji_only=True)Labels are dropped and the buttons shrink to squares. Useful once you have five of them and the row is eating the message width.
A button with an emoji and no label is fine. A button with neither is
an error — if emoji_only=True and a button has no emoji, it keeps its
label rather than rendering blank.
Custom buttons
add_button() puts your own buttons underneath the pager.
p.add_button("Refresh", action="reload", style="secondary")Parameters
| Name | Default | What it does |
|---|---|---|
label | required | button text |
style | "secondary" | primary, secondary, success, danger |
action | "next" | a page action — see below |
emoji | None | unicode, or <:name:id> |
url | None | makes it a link button |
row | 2 | which row it sits on; row 1 is the pager |
on_click | None | your own function |
Page actions
action accepts: first, prev, next, last, stop, jump, reload.
Use it to build a second pager in your own style:
p.add_button("⏮", action="first", style="secondary", row=2)
p.add_button("⏭", action="last", style="secondary", row=2)
p.add_button("Refresh", action="reload", style="success", row=2)Your own function
Pass on_click and the button runs your code instead. It receives the
paginator and the interaction, and may be sync or async.
async def add_to_cart(pag, interaction):
item = items[pag.current_page]
await cart.insert({"user": interaction.user.id, "item": item["id"]})
p.add_button("Add to cart", style="success", on_click=add_to_cart)Because you get the paginator, the callback can move pages itself:
async def skip_five(pag, interaction):
await pag.get_page(pag.current_page + 5)
p.add_button("Skip 5", on_click=skip_five)The buttons are redrawn after your callback runs, so any page change you make shows up straight away.
If your callback raises, the person who clicked gets a private message naming the error, and the paginator stays alive:
raise Exception("You cannot afford that.")
# the clicker sees: Paginator error: Exception: You cannot afford that.Link buttons
p.add_button("Open the docs", url="https://ccbot.app")Discord never sends an interaction for a link button, so it costs nothing and never expires.
Rows
Row 1 is the pager. Your buttons default to row 2. Discord allows 5 rows of 5 buttons each; a Jump menu is placed on a row of its own, below everything else, because a menu cannot share a row with buttons.
A paginator takes at most 20 custom buttons.
Who can drive it
By default, only the person who ran the command. Everyone else gets a private "this paginator belongs to someone else" and the pages do not move.
p = PaginatorV2(interaction=interaction, public=True)public=True lets anyone in the channel drive it — good for a leaderboard,
bad for someone's private inventory.
To hand it to a specific person:
p = PaginatorV2(interaction=interaction, user_id=target.id)Sessions are keyed by a 48-bit random id, so two paginators — in the same server or in different ones — can never answer each other's buttons.
Timeout
timeout is in seconds.
p = PaginatorV2(interaction=interaction, timeout=600) # 10 minutes- Default: 180 (three minutes)
- Allowed: 1 to 3600 (one hour)
Outside that range is an error, not a silent adjustment — asking for 5000 and quietly getting 3600 is the sort of thing you only discover with a stopwatch.
Every click pushes the timer back. It measures idle time, not total life, so a paginator someone is actively reading stays open.
When it expires the buttons are greyed out, so nobody clicks a dead paginator and gets Discord's red error.
Starting, waiting, stopping
start()
await p.start()Sends page one and returns. Your script keeps going.
To send into a different channel instead of replying:
await p.start(channel=get_channel("logs"))wait() — only if you need it
exit_code = await p.wait()
if exit_code == "timeout":
set_error("You took too long.")Returns "stopped" or "timeout", the same as View.wait().
Only use this when something must happen after the pages close. It blocks
your command for as long as the paginator lives, which is exactly what
start() exists to avoid.
stop()
await p.stop()Closes the session and greys the buttons out.
Useful properties
p.current_page # 0 for the first page
p.total_pages # how many pages there are
await p.get_page(3) # move to page 4 (indexes start at 0)get_page() clamps out-of-range numbers to the first or last page rather
than failing.
Limits
| Limit | Value |
|---|---|
| Pages per paginator | 200 |
| Custom buttons | 20 |
| Paginators per execution | 3 |
| Timeout | 1–3600 seconds |
| Jump menu options | 25 (a text box is used beyond that) |
When something goes wrong
A click after the session ended gets a private "this paginator has expired, run the command again". No red error.
An error while drawing a page is reported privately to the person who clicked, and the paginator survives.
A deleted message closes the session quietly — there is nothing left to page.
A complete example
items = await shop.find({})
p = PaginatorV2(
interaction=interaction,
timeout=600,
show_first=True,
show_last=True,
show_jump=True,
emoji_only=True,
)
for item in items:
p.add_page(Message(
title=item["name"],
description=item["description"],
thumbnail=item["image"],
color="#5865F2",
footer="Item %current_page% of %total_page%",
))
async def buy(pag, i):
item = items[pag.current_page]
money = await wallet.find_one({"user_id": str(i.user.id)})
if not money or money["coins"] < item["price"]:
raise Exception("You cannot afford that.")
await wallet.update_one(
{"user_id": str(i.user.id)},
{"$inc": {"coins": -item["price"]}},
)
await inventory.insert({"user_id": str(i.user.id), "item": item["id"]})
p.add_button("Buy", style="success", on_click=buy, row=2)
p.add_button("Full shop", url="https://ccbot.app/shop", row=2)
await p.start()The command ends the moment start() returns. The shop stays open for ten
minutes of browsing, closes itself when nobody is reading, and the Buy
button reports its own errors without taking the pages down.
Frequently asked questions
How do I make a multi-page embed in Discord?
Create a paginator, add one Message per page, then start it. Discord has
no built-in pagination — the buttons and the page tracking come from the
bot, which is what this does for you.
Can more than one person click the buttons?
By default no — only the member who ran the custom command. Pass
public=True to let anyone in the channel page through it, or
user_id=someone.id to hand it to a specific person.
How long does a Discord paginator stay open?
Three minutes of inactivity by default, and up to one hour with
timeout=3600. Every click pushes the timer back, so a paginator someone is
reading stays open. When it expires the buttons grey out instead of
throwing Discord's red "interaction failed".
Does my custom command keep running while the pages are open?
No, and that is deliberate. start() sends page one and returns, so the
rest of your command runs straight away. Only call wait() if something
must happen after the pages close.
Can I add my own buttons to a Discord paginator?
Yes. add_button() takes a label, a style, an emoji and either a page
action (first, prev, next, last, stop, jump, reload), a link
url, or your own on_click function.
How many pages can one paginator hold?
Up to 200, with at most 20 custom buttons, and 3 paginators per command run.
Loading comments…
Need help?
Have a question, a suggestion, or stuck on something? Reach out — we're happy to help.