Custom Commands homeCustom Commands
  • Blog
  • Premium
  • Templates
  • Support
Back to blog
// blog

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

·10 min read

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

python
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

python
p.add_page("Just some text")

A Message

The same builder you use everywhere else in a script.

python
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.

python
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

python
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:

PlaceholderBecomes
%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.

python
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.

python
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

python
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:

code
.venv/bin/python scripts/upload_paginator_emojis.py --apply

Name them exactly:

ButtonEmoji name
Firstpg_first
Prevpg_prev
Nextpg_next
Lastpg_last
Stoppg_close
Jumppg_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:

python
p = PaginatorV2(interaction=interaction, auto_emojis=False)

Your own emojis

python
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

python
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.

python
p.add_button("Refresh", action="reload", style="secondary")

Parameters

NameDefaultWhat it does
labelrequiredbutton text
style"secondary"primary, secondary, success, danger
action"next"a page action — see below
emojiNoneunicode, or <:name:id>
urlNonemakes it a link button
row2which row it sits on; row 1 is the pager
on_clickNoneyour own function

Page actions

action accepts: first, prev, next, last, stop, jump, reload.

Use it to build a second pager in your own style:

python
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.

python
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:

python
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:

python
raise Exception("You cannot afford that.")
# the clicker sees: Paginator error: Exception: You cannot afford that.

Link buttons

python
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.

python
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:

python
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.

python
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()

python
await p.start()

Sends page one and returns. Your script keeps going.

To send into a different channel instead of replying:

python
await p.start(channel=get_channel("logs"))

wait() — only if you need it

python
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()

python
await p.stop()

Closes the session and greys the buttons out.


Useful properties

python
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

LimitValue
Pages per paginator200
Custom buttons20
Paginators per execution3
Timeout1–3600 seconds
Jump menu options25 (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

python
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.

Join support server
Custom CommandsCustom Commands

The #1 custom commands Discord bot — build commands, events and databases with zero boilerplate.

Links

HomeTemplatesPremiumBlogPrivacySupport

Contact

[email protected]

2 Frederick StreetLondon, WC1X 0ND

{ / } custom commands

© WEiRDSOFT LTD. All rights reserved.