Skip to content

Instantly share code, notes, and snippets.

@Soheab
Last active September 21, 2025 02:07
Show Gist options
  • Select an option

  • Save Soheab/891c39d7294b1bdbadc7ecf35ce51cc5 to your computer and use it in GitHub Desktop.

Select an option

Save Soheab/891c39d7294b1bdbadc7ecf35ce51cc5 to your computer and use it in GitHub Desktop.
from __future__ import annotations
from typing import (
Generic,
Optional,
TypeVar,
Any,
TYPE_CHECKING,
)
import discord
if TYPE_CHECKING:
from typing_extensions import Self
SequenceT = TypeVar("SequenceT")
Seq = list[SequenceT] | tuple[SequenceT, ...]
Sequence = Seq[SequenceT | Seq[SequenceT]]
PossibleMedia = (
discord.File
| discord.Attachment
| discord.MediaGalleryItem
| discord.UnfurledMediaItem
)
PossiblePage = (
str
| discord.ui.TextDisplay[Any]
| discord.ui.Container[Any]
| discord.ui.File[Any]
| discord.ui.Section[Any]
| discord.ui.MediaGallery[Any]
| PossibleMedia
)
PageT_co = TypeVar(
"PageT_co",
bound=PossiblePage,
covariant=True,
)
class ButtonPaginator(discord.ui.LayoutView, Generic[PageT_co]):
"""A paginator that uses buttons to navigate through so called "pages".
Parameters
----------
pages: Sequence[PageT_co]
A sequence of pages to paginate through. Each page can be a string, TextDisplay,
Container, File, Section, or MediaGallery. Or a list/tuple of those.
If a string is provided, it will be converted to a TextDisplay.
Of course, you can also pass media such as discord.File or discord.Attachment.
If you pass a discord.Attachment, it will be converted to a discord.File automatically.
Other media types such as MediaGalleryItem and UnfurledMediaItem are also supported,
but need further processsing in the `format_page` method, for obvious reasons.
author_id: int | None
The ID of the user who is allowed to interact with the paginator. If None, anyone can interact.
Defaults to None.
timeout: float | None
The amount of time in seconds before the paginator times out. If None, the paginator will
wait indefinitely for user interaction. Defaults to 180.0 seconds.
delete_message_after: bool
Whether to delete the message containing the paginator after the paginator times out or is stopped.
Defaults to False.
disable_items_after: bool
Whether to disable all interactive items in the paginator after it times out or is stopped.
Defaults to False.
per_page: int
The number of pages to display per page. Must be at least 1 and less than or equal to the total number of pages. Defaults to 1.
container: discord.ui.Container[Any] | bool | None
A Container to hold the pages. Can be one of:
- A `discord.ui.Container` instance: The provided container will be used to hold the pages.
- `True`: A new Container will be created automatically to hold the pages.
- `None`: No Container will be used.
Defaults to None.
container_accent_color: discord.Color | int | None
The accent color set for the created container. Only applicable if `container` is set to `True`.
add_buttons_to_container: bool
Whether to add the navigation buttons to the container if one is used. If False, the buttons
will be added to the main view. Defaults to False.
title: str | discord.ui.TextDisplay[Any] | None
An optional title to display at the top of each page. Can be a string or a TextDisplay. If a string is provided,
it will be converted to a TextDisplay. Defaults to None.
description: str | discord.ui.TextDisplay[Any] | None
An optional description to display below the title on each page. Can be a string or a TextDisplay. If a string is provided,
it will be converted to a TextDisplay. Defaults to None.
convert_str_to_text_display: bool
Whether to convert strings in the `pages` parameter to TextDisplay instances. If False, strings will be used as-is.
Defaults to False.
"""
message: discord.Message | None = None
buttons_action_row: discord.ui.ActionRow[Self] = discord.ui.ActionRow(id=373)
def __init__(
self,
pages: Sequence[PageT_co],
*,
author_id: int | None = None,
timeout: float | None = 180.0,
delete_message_after: bool = False,
disable_items_after: bool = False,
per_page: int = 1,
container: discord.ui.Container[Any] | bool | None = None,
container_accent_color: discord.Color | int | None = None,
add_buttons_to_container: bool = False,
title: str | discord.ui.TextDisplay[Any] | None = None,
description: str | discord.ui.TextDisplay[Any] | None = None,
convert_str_to_text_display: bool = False,
) -> None:
super().__init__(timeout=timeout)
self.remove_item(self.buttons_action_row)
self._add_buttons_to_container = add_buttons_to_container
self.author_id: Optional[int] = author_id
self.delete_message_after: bool = delete_message_after
self.disable_items_after: bool = disable_items_after
self.__convert_str_to_text_display = convert_str_to_text_display
self._per_page = per_page
self._pages = pages
self._current_page_index = 0
self.per_page = self._per_page
self.current_page_index = self._current_page_index
self.pages = self._pages
self._title: discord.ui.TextDisplay[Any] | None = (
discord.ui.TextDisplay[Any](str(title))
if title is not None and not isinstance(title, discord.ui.TextDisplay)
else title if isinstance(title, discord.ui.TextDisplay) else None
)
self._description: discord.ui.TextDisplay[Any] | None = (
discord.ui.TextDisplay[Any](str(description))
if description and not isinstance(description, discord.ui.TextDisplay)
else (
description if isinstance(description, discord.ui.TextDisplay) else None
)
)
if container_accent_color and container is not True:
raise ValueError(
"container_accent_color may only be set if container is True. "
"This means a new Container is created automatically with the given accent color."
)
self._container: discord.ui.Container[Any] | None = None
if container is not None:
if isinstance(container, discord.ui.Container):
self._container = container
elif container is True:
self._container = discord.ui.Container(
accent_color=container_accent_color,
)
else:
raise TypeError(
"Container must be a discord.ui.Container instance, True, or None."
)
self._buttons_container: discord.ui.Container[Any] | None = self._container
self.__initial_container_items_count: int = (
len(self._container.children) if self._container else 0
)
@property
def current_pages(self) -> list[PageT_co]:
return self.get_chunk(self.current_page_index)
@property
def max_pages(self) -> int:
if self.per_page < 1:
return 1
return (len(self.pages) + self.per_page - 1) // self.per_page
@property
def pages(self) -> Sequence[PageT_co]:
return self._pages #
@pages.setter
def pages(self, value: Sequence[PageT_co]) -> None:
if not value:
raise ValueError("Pages cannot be empty.")
if self.__convert_str_to_text_display:
# fmt: off
value = ( # pyright: ignore[reportUnknownVariableType, reportAssignmentType]
[
discord.ui.TextDisplay(page) if isinstance(page, str) else page
for page in value
]
)
# fmt: on
self._pages = value
self._current_page_index = 0
@property
def current_page_index(self) -> int:
return self._current_page_index
@current_page_index.setter
def current_page_index(self, value: int) -> None:
if value < 0:
self._current_page_index = 0
elif value >= self.max_pages:
self._current_page_index = self.max_pages - 1
else:
self._current_page_index = value
@property
def per_page(self) -> int:
return self._per_page
@per_page.setter
def per_page(self, value: int) -> None:
if value < 1:
raise ValueError("per_page must be at least 1.")
elif value > len(self.pages):
value = len(self.pages)
self._per_page = value
def stop(self) -> None:
self.message = None
super().stop()
async def timeout_actions(
self, interaction: discord.Interaction[Any] | None = None
) -> None:
"""Handle actions such as deleting the message or disabling items after timeout or stop.
This is called by default in `on_timeout` and `on_stop`.
Parameters
----------
interaction: discord.Interaction[Any] | None
The interaction to edit. If None, it will try to edit the set `message`.
If no interaction is provided and no message is set, this function does nothing.
Defaults to None.
"""
if not self.message and not interaction:
return
message: discord.Message | None = self.message
interaction_message: discord.Message | None = (
interaction and interaction.message
)
if self.delete_message_after:
if interaction:
if not interaction.response.is_done():
await interaction.response.defer()
try:
await interaction.delete_original_response()
except Exception:
if interaction_message:
await interaction_message.delete()
elif message:
await message.delete()
elif message:
await message.delete()
return
if self.disable_items_after:
for child in self.walk_children():
if hasattr(child, "disabled"):
child.disabled = True # pyright: ignore[reportAttributeAccessIssue]
if interaction:
if not interaction.response.is_done():
await interaction.response.edit_message(view=self)
else:
try:
await interaction.edit_original_response(view=self)
except Exception:
if interaction_message:
await interaction_message.edit(view=self)
elif message:
await message.edit(view=self)
elif message:
await message.edit(view=self)
async def on_timeout(
self, interaction: discord.Interaction[Any] | None = None
) -> None:
await self.timeout_actions(interaction)
async def interaction_check(self, interaction: discord.Interaction[Any]) -> bool:
if not self.author_id:
return True
if self.author_id != interaction.user.id:
await interaction.response.send_message(
"You cannot interact with this menu.", ephemeral=True
)
return False
return True
def get_chunk(self, page_number: int) -> list[PageT_co]:
if page_number < 0 or page_number >= self.max_pages:
self.current_page_index = 0
res = self.pages[self.current_page_index]
if not isinstance(res, (list, tuple)):
res = [res]
return list(res)
if self.per_page == 1:
res = self.pages[page_number]
else:
base = page_number * self.per_page
res = self.pages[base : base + self.per_page]
if not isinstance(res, (list, tuple)):
res = [res]
return list(res) # pyright: ignore[reportReturnType]
def format_page(self, chunk: Sequence[PageT_co]) -> Sequence[PageT_co]:
return chunk
async def get_page_kwargs(
self, pages: Sequence[PageT_co], skip_formatting: bool = False
) -> dict[str, Any]:
if not pages:
raise ValueError("No pages found.")
formatted_pages: PageT_co | Sequence[PageT_co]
if not skip_formatting:
self.clear_items()
if self._container:
for _ in range(
len(self._container.children) - self.__initial_container_items_count
):
self._container.remove_item(self._container.children[-1])
self._page_kwargs: dict[str, Any] = {
"files": [],
"view": self,
}
formatted_pages = await discord.utils.maybe_coroutine(
self.format_page, pages
)
if self._title:
if self._container:
self._container.add_item(self._title)
else:
self.add_item(self._title)
if self._description:
if self._container:
self._container.add_item(self._description)
else:
self.add_item(self._description)
else:
formatted_pages = pages
if isinstance(
formatted_pages, (tuple, list)
): # pyright: ignore[reportUnnecessaryIsInstance] # no, it's not unnecessary
for item in formatted_pages:
await self.get_page_kwargs(
item, skip_formatting=True # pyright: ignore[reportArgumentType]
)
if isinstance(formatted_pages, (discord.File, discord.Attachment)):
if isinstance(formatted_pages, discord.Attachment):
formatted_pages = (
await formatted_pages.to_file()
) # pyright: ignore[reportAssignmentType]
self._page_kwargs.setdefault("files", []).append(formatted_pages)
if isinstance(formatted_pages, str):
formatted_pages = discord.ui.TextDisplay( # pyright: ignore[reportUnknownVariableType, reportAssignmentType]
formatted_pages
)
if isinstance(formatted_pages, discord.ui.Item):
if self._container and not isinstance(
formatted_pages, discord.ui.Container
):
self._container.add_item(formatted_pages)
else:
self.add_item(formatted_pages)
if (
self._add_buttons_to_container
and isinstance(formatted_pages, discord.ui.Container)
and not self._buttons_container
):
self._buttons_container = formatted_pages
return self._page_kwargs
def update_buttons(self) -> None:
self.previous_page.disabled = self.max_pages < 2 or self.current_page_index <= 0
self.next_page.disabled = (
self.max_pages < 2 or self.current_page_index >= self.max_pages - 1
)
if self._container and self._container not in self.children:
self.add_item(self._container)
if self._add_buttons_to_container:
buttons_target: discord.ui.Container[Any] | discord.ui.LayoutView | None = (
self._buttons_container or self._container
)
if not buttons_target:
buttons_target = self._buttons_container = discord.ui.Container()
self.add_item(buttons_target)
else:
buttons_target = self
if (
isinstance(buttons_target, discord.ui.Container)
and buttons_target not in self.children
):
self.add_item(buttons_target)
if not buttons_target.find_item(373):
buttons_target.add_item(self.buttons_action_row)
async def update_page(self, interaction: discord.Interaction[Any]) -> None:
if self.message is None:
self.message = interaction.message
kwargs = await self.get_page_kwargs(self.current_pages) # type: ignore
self.update_buttons()
self.reset_files(kwargs)
kwargs["attachments"] = kwargs.pop("files", [])
await interaction.response.edit_message(**kwargs)
async def on_stop(self, interaction: discord.Interaction) -> Any:
"""Method called when the stop button is used.
This method exits to allow for custom behaviour when the stop button is pressed.
By default, it does the following in order:
1. If neither `disable_items_after` nor `delete_message_after` is set to `True`, it sends
an ephemeral message saying "Stopped the paginator."
2. Calls `timeout_actions` to manage post-stop actions like deleting the message or disabling items.
3. Stops the paginator by calling `self.stop()`.
Parameters
----------
interaction: discord.Interaction
The interaction that triggered the stop.
"""
if not any([self.disable_items_after, self.delete_message_after]):
await interaction.response.send_message(
"Stopped the paginator.", ephemeral=True
)
await self.timeout_actions(interaction)
self.stop()
@buttons_action_row.button(
label="Previous", style=discord.ButtonStyle.blurple, emoji="⬅️"
)
async def previous_page(
self, interaction: discord.Interaction[Any], _: discord.ui.Button[Self]
) -> None:
self.current_page_index -= 1
await self.update_page(interaction)
@buttons_action_row.button(
label="Next", style=discord.ButtonStyle.blurple, emoji="➡️"
)
async def next_page(
self, interaction: discord.Interaction[Any], _: discord.ui.Button[Self]
) -> None:
self.current_page_index += 1
await self.update_page(interaction)
@buttons_action_row.button(label="Stop", style=discord.ButtonStyle.red, emoji="⏹️")
async def stop_paginator(
self, interaction: discord.Interaction[Any], _: discord.ui.Button[Self]
) -> None:
await self.on_stop(interaction)
def reset_files(self, page_kwargs: dict[str, Any]) -> None:
files: list[discord.File] = page_kwargs.get("files", [])
if not files:
return
for file in files:
file.reset()
async def start(
self, obj: discord.Interaction | discord.abc.Messageable, **send_kwargs: Any
) -> discord.Message:
"""Starts the paginator by sending the initial message.
Parameters
----------
obj: discord.Interaction | discord.abc.Messageable
The interaction or messageable (like a TextChannel) to send the paginator message to.
send_kwargs: Any
Additional keyword arguments to pass to the sending method.
Returns
-------
discord.Message
The message sent by the paginator.
Raises
------
TypeError
If `obj` is neither an Interaction nor a Messageable.
ValueError
If there are no pages to display.
"""
kwargs = await self.get_page_kwargs(self.current_pages) # type: ignore
self.update_buttons()
if self.max_pages < 2:
for child in self.buttons_action_row.walk_children():
if hasattr(child, "disabled"):
child.disabled = True # pyright: ignore[reportAttributeAccessIssue]
self.reset_files(kwargs)
message: discord.Message | None = None
if isinstance(obj, discord.Interaction):
if obj.response.is_done():
message = await obj.followup.send(**kwargs, wait=True, **send_kwargs)
else:
response = await obj.response.send_message(**kwargs, **send_kwargs)
if isinstance(response.resource, discord.InteractionMessage):
message = response.resource
else:
message = await obj.original_response()
elif isinstance(
obj, discord.abc.Messageable
): # pyright: ignore[reportUnnecessaryIsInstance]
message = await obj.send(**kwargs, **send_kwargs)
else:
raise TypeError(
f"Expected Interaction or Messageable, got {obj.__class__.__name__}"
)
self.message = message
return message

Components V2 Paginator

Looking for a non-cv2 paginator? Go to https://gist.github.com/Soheab/f226fc06a3468af01ea3168c95b30af8

Usage

  1. Give this gist a ⭐️ :P
  2. Copy and paste the contents above into a file like utils/paginator.py
  3. Import ButtonPaginator and subclass it or use it directly anywhere.

Arguments

Argument Type Default Description
pages str, TextDisplay, Container, File, Section, MediaGallery, list/tuple Required Content to paginate. Lists/tuples allow multiple items per page.
per_page= int 1 Number of pages to show at once. See Per Page.
author_id= int or None None User ID allowed to interact with the paginator. Defaults to everyone.
timeout= float 180.0 View timeout in seconds.
delete_message_after= bool False Whether to delete the message after the paginator is stopped or timed out.
disable_items_after= bool False Whether to disable all interactive items after the paginator is stopped or timed out.
container= Container, bool, or None None The container to add all pages to. If `True`, a container will be created.
container_accent_color= Any None Accent color for created Container.
add_buttons_to_container= bool False Whether to add buttons to the created container or the last container page.
title= str or TextDisplay None Title for the current chunk of pages, shown above the pages.
description= str or TextDisplay None Description for the current chunk of pages, shown below the title.
convert_str_to_text_display= bool False Whether to convert all `str` pages to `TextDisplay` in the `__init__`. If `False` (default), conversion is done each time a page is sent.

Example

# different for you ofc
from utils.paginator import ButtonPaginator
# or
from utils import paginator
ButtonPaginator = paginator.ButtonPaginator

# Using
# like in a command

# can be a list of strings/components/files/dict
# need to attach a file to a ui.File or ui.MediaGalleryItem? pass both as list/tuples:
# (<Item>, <File>)
# need multiple files? that's supported too! either in the constructor or from format_page as a list/tuple.
pages = ["hello", "world", "foo", discord.File(...), ]
paginator = ButtonPaginator(pages)
await paginator.start(<interaction or messageable (ctx, text channel, etc)>)


# Subclassing (recommended)
# can be used to pass custom stuff or format the page
class TestPaginator(ButtonPaginator[T]):
  # can be async
  def format_page(self, chunk: list[T]):
    # add "TEST" to all string pages
    for page in chunk:
      if isinstance(page, str):
        page += "\n\nTEST"
      elif isinstance(page, discord.ui.TextDisplay):
        page.content += "\n\nTEST"

    return chunk

# using TestPaginator(pages, ...) instead of ButtonPaginator(pages, ...)
await TestPaginator(...).start(<interaction or messageable (ctx, text channel, etc)>)

Per Page

You can change how many pages are shown at once with the per_page argument. This is useful for showing multiple items together, like images in a gallery.

When using per_page, you can use format_page to format the entire chunk at once.

# pages = ["hello", "world", "foo", "bar"]

class MyPaginator(ButtonPaginator[str]):
    # Can be async
    def format_page(self, chunk: list[str]):
        print(chunk) # ["hello", "world"]
        # or [<discord.ui.TextDisplay>, <discord.ui.TextDisplay>] if convert_str_to_text_display is True
    ...

Note: chunk is always a sequence of pages with a length equal to per_page, except for the last chunk, which may be shorter. This applies even if per_page is 1 (the default).

On Stop

You can override the on_stop method to customize what happens when the paginator is stopped. This method is called when the stop button is pressed.

By default, it sends a message saying "Stopped the paginator." AND handles deleting the message or disabling items based on the parameters you set.

class MyPaginator(ButtonPaginator[str]):
    async def on_stop(self, interaction: discord.Interaction) -> None:
        ... # do anything you want here

        # default behavior:
        if not any([self.disable_items_after, self.delete_message_after]):
            await interaction.response.send_message(
                "Stopped the paginator.", ephemeral=True
            )

        await self.timeout_actions(interaction)
        self.stop()

If you choose to override, you can call await self.timeout_actions(interaction) to perform the default timeout actions (deleting the message or disabling items) if wanted. Otherwise, you can implement your own logic.

Changelog

See here an user-friendly changelog of all notable changes made to this project.

17 September, 2025

Added

  • Added on_stop method to be called when the paginator is stopped. You can override this method to add custom behavior when the paginator is stopped.
  • Added timeout_actions method to handle actions such as deleting the message or disabling items after timeout or stop. This method is called in both on_timeout and on_stop.

Bug Fixes

  • Fixed an issue where the paginator would not listen to interactions if per_page was to a value greater than the number of pages.

Miscellaneous

  • per_page can now longer be set to a value that exceeds the number of pages. It will be set to the number of pages instead.
  • Improved type hints and code quality. Now compliant with pyright's strict mode and Python 3.12+.
  • Added some inline documentation for better understanding of the code.
  • Providing less than 2 pages will no longer remove all items on the view and stop it. Only the buttons will be disabled now.
  • pages and per_page can now be safely modified after initialization. Other attributes will be updated accordingly. Note: you still need to edit the message.

11 September, 2025

Added

  • Introduced three new parameters: title, description, and convert_str_to_text_display. See How to Use for more details.
  • Added current_page_index attribute to get the current page index (0-based).
  • Added current_pages attribute to get the current chunk of pages being displayed.

Bug Fixes

  • Resolved an issue where if you passed a container with existing items, those items would be removed when the paginator is interacted with.
  • Resolved an issue where buttons were not added to a container or the correct one when add_buttons_to_container was set to True.
  • Resolved an issue where the buttons were removed while interacting with the paginator.

Miscellaneous

  • format_page now always receives a list of pages (a chunk) instead of a single page, regardless of the per_page parameter. The single parameter has also been renamed to chunk.
  • Renamed get_page to get_chunk for clarity.

Removals

  • current_page attribute has been removed in favour of current_page_index and current_pages for a clearer API.

8 September, 2025

Added

  • Introduced the disable_items_after parameter to disable all interactable items after stopping and upon timeout.

Bug Fixes

  • Resolved an issue where the content could appear twice.
  • Fixed a bug that prevented starting the paginator with only one page; the buttons are now correctly removed in this scenario.

Miscellaneous

  • Enhanced overall code quality and type hints. Removed unused imports and refined the handling of Container and buttons.

20 August, 2025

  • Corrected a NameError in the __init__.
@ASF007
Copy link

ASF007 commented Aug 20, 2025

L 60: self._auto_convert_string = auto_convert_string needs to be removed I believe.

@Soheab
Copy link
Author

Soheab commented Aug 20, 2025

L 60: self._auto_convert_string = auto_convert_string needs to be removed I believe.

Yup, good catch!

@GoddeerisEdouard
Copy link

GoddeerisEdouard commented Sep 10, 2025

Adding a container in the kwarg with a TextDisplay inside overwrites it with the pages, is this intended?

# ...
container = discord.ui.Container()
# The existing TextDisplay in this container is never shown
container.add_item(discord.ui.TextDisplay("Anything here"))
pages = ["foo", "bar"]

bp = ButtonPaginator(pages, per_page=5, disable_items_after=True, container=container)
await bp.start(interaction, ephemeral=True)

Also, the stop button interaction fails in this case

@Soheab
Copy link
Author

Soheab commented Sep 12, 2025

Adding a container in the kwarg with a TextDisplay inside overwrites it with the pages, is this intended?

# ...
container = discord.ui.Container()
# The existing TextDisplay in this container is never shown
container.add_item(discord.ui.TextDisplay("Anything here"))
pages = ["foo", "bar"]

bp = ButtonPaginator(pages, per_page=5, disable_items_after=True, container=container)
await bp.start(interaction, ephemeral=True)

Also, the stop button interaction fails in this case

Fixed!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment