Skip to content

API: Components

Type Aliases

EmojiInput

EmojiInput = Optional[Union[str, discord.Emoji, discord.PartialEmoji]]

Defined in cascadeui.components.types. Used by every emoji= parameter in CascadeUI's typed surface (button builders, pattern ClassVar attributes, the refresh handoff). Mirrors the union accepted by discord.ui.Button. See Custom Emoji for the three string forms and application emoji setup.

MediaInput

MediaInput = Union[str, discord.File, discord.UnfurledMediaItem]

Defined in cascadeui.components.types, exported from the package root. Used by every media parameter in the V2 builders: gallery(*media), image_section(url=...), file_attachment(url), and LeaderboardLayoutView(banner=...).

The annotation names the three shapes the builders resolve by type, and one more reaches them besides. A string is used as-is, whether it is a remote URL or the attachment://name.ext form. A discord.File resolves to its .uri. A discord.UnfurledMediaItem passes through unchanged. Anything else carrying a string .url is read from that attribute, which is what makes member.display_avatar work where member.display_avatar.url was meant. Anything else raises TypeError at construction, naming the builder and the argument. An empty or whitespace-only reference raises ValueError at the same seam; LeaderboardLayoutView(banner=...) alone normalizes a blank value to None (no banner), since absence is a banner's documented meaning.

A discord.File supplies only the reference. The bytes travel separately through view.send(files=[...]) or view.refresh(attachments=[...]) -- see Local file attachments.

MAX_SELECT_OPTIONS

MAX_SELECT_OPTIONS = 25

Defined in cascadeui.components.types, exported from the package root. Discord's hard cap on the number of options in a single select menu. choice_row enforces it, raising ValueError past the cap.

MAX_COMPONENT_ID

MAX_COMPONENT_ID = 2**31 - 1

Defined in cascadeui.components.types, exported from the package root. Discord's upper bound on a component id. See Naming a component below.

MAX_MESSAGE_COMPONENTS

MAX_MESSAGE_COMPONENTS = 40

Defined in cascadeui.components.types, exported from the package root. Discord's cap on the components in a single V2 message, counted recursively: every Container, Section, ActionRow, button, select, and text node, a Section's accessory included. Exactly this many is legal and the next one is refused.

discord.py owns the enforcement and raises from add_item while the tree is being built, so this constant is for a budget check a caller wants to run before composing. Pair it with total_components and count_components.


MAX_MESSAGE_CHARACTERS

MAX_MESSAGE_CHARACTERS = 4000

Defined in cascadeui.components.types, exported from the package root. Discord's cap on the display text in a single V2 message, summed across every item. The per-node cap on one TextDisplay is also 4000, and the two are different limits: ten short text nodes pass every per-node check and can still cross this one.

Nothing enforces it. Discord documents the limit and discord.py exposes the running total as LayoutView.content_length() without raising on it, so a tree over the cap builds cleanly, passes the placement validator, and is refused at send. That counter sums TextDisplay content only: text in a button label, a select placeholder, or an option label reads as zero against it, so a control-heavy screen can approach the cap while the total does not. A V2 view over the cap logs a warning naming the measured total at every seam that ships a tree; the library does not reject the send, because refusing a tree Discord would have accepted is the worse error. Compare content_length() against this constant to check a budget while composing.


EntryList

EntryList = List[Tuple[int, dict]]

Exported from the package root beside EmojiInput and MediaInput. The shape LeaderboardLayoutView.get_entries() resolves to: (user_id, stats) pairs. Annotate an override with EntryList whichever shape it takes: an async def's return annotation names the value it resolves to, so both a plain def and an async def reading an awaited source annotate the same way.

from cascadeui import EntryList, LeaderboardLayoutView

class GuildRankings(LeaderboardLayoutView):
    async def get_entries(self) -> EntryList:
        return await self.fetch_rankings()

Naming a component

Every V2 builder that returns exactly one component takes an optional id=, an integer that names that component within its message:

card("Standings", id=10)
image_section("Avatar", url=member.display_avatar, id=11)

Discord assigns ids sequentially from 1 when you supply none, so passing your own is only worth doing when something outside the builder needs to refer to a specific node. Ids must be positive and no larger than MAX_COMPONENT_ID, and no two components in one message may share one -- all three are checked before the message is sent, naming the builder and the offending value.

The two builders that return a list take no id=: confirm_section yields a text display plus a row of buttons, and button_grid yields one row per grid row, so there is no single node for an id to mean. Set ids on the pieces you build yourself if you need them.


StatefulComponent

The base mixin every stateful component extends, including StatefulButton, StatefulSelect, the five modal input wrappers, and Modal itself. It supplies one method:

create_stateful_callback(component, original_callback=None)

Wraps a callback so the component dispatches a COMPONENT_INTERACTION action around it, binds the live interaction so the acting view's refresh can take the one-request fast path, and passes the component's values as a second argument when the callback declares one. A callback that cannot accept the arguments the component will call it with ((interaction) for a button, (interaction) or (interaction, values) for a select) raises TypeError at construction, printing the signature it saw, instead of a bare arity error on the first click. A callable whose signature cannot be read is allowed through. Subclass StatefulComponent alongside a discord.ui primitive to give a custom component the same behavior:

class TimeSelect(StatefulComponent, discord.ui.Select):
    def __init__(self, *, callback=None, **kwargs):
        super().__init__(**kwargs)
        self.original_callback = callback
        if callback:
            self.callback = self.create_stateful_callback(self, callback)

The bundled components cover the common cases; reach for this only when wrapping a discord.ui primitive the library does not already ship.


StatefulButton

Extends discord.ui.Button with automatic state dispatching.

StatefulButton(
    label=None,
    style=ButtonStyle.secondary,
    custom_id=None,        # Required for PersistentView
    callback=fn,           # def callback(interaction) or async def
    owner_only=False,      # When True, only view.user_id can click; non-owner clicks route to view.on_unauthorized
    emoji=None,
    disabled=False,
    row=None,
)

Callbacks and hooks take either shape

Every callback=, on_* hook, and builder function on this page accepts a plain def as readily as an async def. The library resolves what your function returned rather than inspecting the function, so a callable object with an async __call__ and a functools.partial around one work too. The async def spellings below are examples, not requirements. See Sync or Async, Your Choice for the few seams that genuinely require a synchronous function.

Every click dispatches a COMPONENT_INTERACTION action. Skips dispatch when the parent view is finished.

The callback takes one positional argument, the interaction. A button carries no value, so a callback declaring a required second parameter (or an unbound method still carrying self) raises TypeError at construction, naming the signature it saw. Close over any extra data, or reach for toggle_button / cycle_button / choice_row when the callback needs the control's value.


StatefulSelect

Extends discord.ui.Select with state integration.

StatefulSelect(
    placeholder=None,
    options=[SelectOption(...)],
    callback=fn,           # sync or async
    custom_id=None,        # Required for PersistentView; recommended in _build_extra_items
    owner_only=False,      # Same per-component host gate StatefulButton takes
    min_values=1,
    max_values=1,
    row=None,
)

More than 25 options raises a ValueError at construction (MAX_SELECT_OPTIONS), turning a Discord HTTP 400 into a construction-time error. Dropdown inherits the same cap.

options takes SelectOption instances. Anything else raises a TypeError naming the offending index; pass option dicts to Dropdown instead, which converts them.

owner_only is available on StatefulSelect and Dropdown. The four specialized selects (RoleSelect, ChannelSelect, UserSelect, MentionableSelect) wrap their discord.py classes directly and reject the keyword; gate those through the view's interaction_check or allowed_users.

set_selected(value)

Sets which options are marked as default=True. Accepts:

  • None, "", or an empty iterable -- clears all selections
  • A single str -- marks the matching option (the single-select common case)
  • An iterable of strings -- marks every matching option (for max_values > 1)

Values that don't match any existing option are silently ignored, so state-driven rebuilds survive config migrations that drop enum variants.

get_selected() -> list[str]

Returns a list[str] of all option values currently marked default=True, matching discord.py's Select.values always-list convention.

Two-Parameter Callbacks

StatefulSelect callbacks may accept an optional second positional parameter values:

async def on_select(interaction, values):
    selected = values[0]  # list[str] of selected option values
    ...

This is one instance of the library-wide callback grammar: a callback receives the interaction, plus the control's value as a second argument when the control carries one and the callback declares a second parameter. Detection happens at creation time via inspect.signature. Single-parameter callbacks (async def cb(interaction)) still work, and variadic (*args) callbacks keep the one-argument contract -- only callbacks declaring a second positional parameter receive values. A callback that can accept neither shape raises TypeError at construction.

Select Variants

  • Dropdown -- StatefulSelect subclass that also accepts option dicts ({"label": ..., "value": ...}) and converts them. StatefulSelect itself takes SelectOption instances only and rejects anything else at construction
  • RoleSelect -- extends discord.ui.RoleSelect
  • ChannelSelect -- extends discord.ui.ChannelSelect
  • UserSelect -- extends discord.ui.UserSelect
  • MentionableSelect -- extends discord.ui.MentionableSelect

Pre-populated defaults (specialized selects)

RoleSelect, UserSelect, ChannelSelect, and MentionableSelect accept a default_values= constructor kwarg and a set_default_values(values) method. CascadeUI coerces input to discord.py's SelectDefaultValue shape with the right type per select class.

RoleSelect(default_values=[123456789, role_obj])           # auto-typed 'role'
UserSelect(default_values=[member_obj])                    # auto-typed 'user'
ChannelSelect(default_values=[channel_id])                 # auto-typed 'channel'
MentionableSelect(default_values=[member_obj, role_obj])   # type inferred per object

Accepted input per entry:

  • Raw int IDs (RoleSelect / UserSelect / ChannelSelect only -- MentionableSelect rejects bare ints because the type cannot be inferred).
  • Discord.py objects with .id attributes (Member, User, Role, GuildChannel).
  • Pre-built discord.SelectDefaultValue instances (passed through unchanged).

set_default_values(values) replaces the current list. Pass None or [] to clear.


DynamicPersistentButton

Extends discord.ui.DynamicItem[discord.ui.Button] for persistent buttons whose handler depends only on IDs encoded in the custom_id. No view-level state involved; each click re-instantiates the class from the matched custom_id.

class RoleToggleButton(
    DynamicPersistentButton,
    template=r"roles:(?P<category>[a-z_]+):(?P<role_id>[0-9]+)",
):
    def __init__(self, *, category: str, role_id: int):
        button = discord.ui.Button(
            label=f"Toggle {category}",
            custom_id=f"roles:{category}:{role_id}",
            style=discord.ButtonStyle.primary,
        )
        super().__init__(button)
        self.category = category
        self.role_id = role_id

    async def on_click(self, interaction):
        ...

discord.py requires template= on every subclass at class- definition time; abstract intermediate bases are not supported.

auto_defer_delay (default 2.5s) sets when the built-in ack backstop defers a slow on_click. Dynamic items dispatch outside a view's auto-defer, so each button carries its own timer. The value must stay under 3.0, Discord's acknowledgment deadline, and is checked when the subclass is defined.

on_click(interaction) -> None

Override hook for click handling. Default: no-op. Captured values from the custom_id template are available as instance attributes set by the subclass __init__. Use self.respond(...) for replies and self.open_modal(...) for modals (see below).

await respond(interaction, content=None, *, ephemeral=False, **kwargs)

Sends an interaction response, falling back to interaction.followup.send when the response slot is already acked. The ack backstop armed around on_click can consume the slot mid-click, so a bare interaction.response.send_message raises InteractionResponded under load; respond() routes around that. Mirrors the view-level respond().

await open_modal(interaction, modal, *, fallback_message=None) -> bool

Opens a modal, falling back to an ephemeral reply when the response slot is already gone. send_modal() cannot follow a defer, so the same ack backstop that makes respond() necessary makes a bare interaction.response.send_modal fail under load. Returns True when the modal opened, False when the fallback fired. Raises ValueError if the modal has no components. Mirrors the view-level open_modal().

from_custom_id(cls, interaction, item, match) -> cls (classmethod)

Default reconstructs the instance from a matched custom_id. Extracts match.groupdict(), coerces any capture named user_id, guild_id, channel_id, role_id, or message_id to int, and calls cls(**captures). Override when the subclass needs custom extraction (non-snowflake coercion, combined keys, lookup-based restoration).

Auto-registration

Every subclass declaring a template= registers into a module-level registry at class-definition time. The persistent-view reattach pass run by setup_middleware(PersistenceMiddleware(..., bot=bot)) calls bot.add_dynamic_items(*subclasses), so every click routes correctly after a restart with no additional user setup. A subclass imported after that pass (a cog loaded later) is wired in by the same re-drive when PersistenceManager.reattach() runs, matching the late-import recovery for persistent views.


TextInput

Wraps discord.ui.TextInput with a stable custom_id derived from the label. Renders inside a Modal as a discord.ui.Label containing the inner discord.ui.TextInput.

TextInput(
    label=str,               # Required; renders as ui.Label.text
    description=None,        # Optional: ui.Label.description (helper text)
    placeholder=None,
    default=None,
    required=True,
    min_length=None,
    max_length=None,
    style=TextStyle.short,   # or TextStyle.long for multi-line
    validators=None,         # Optional: list of validator functions
)

min_length accepts 0-4000 and max_length accepts 1-4000; an out-of-range bound raises a directed ValueError at construction.

Discord refuses an empty required input but accepts one holding only spaces; Modal refuses that answer too, naming the field, and skips its validators.

The custom_id is auto-generated as "input_{label}" (lowercased, spaces replaced with underscores). Use TextInput._slug(label) to reproduce the same transformation externally.

description= populates ui.Label.description for an optional secondary helper line beneath the title. Available on every wrapped input type (Checkbox, CheckboxGroup, RadioGroup, FileUpload all accept the same kwarg).

validators attaches a list of validator functions directly to the input. Modal auto-collects them at construction time, keyed by each input's custom_id. This is the canonical attachment shape -- there is no separate modal-level validators dict.


Wraps discord.ui.Modal with state integration and automatic validator collection.

Modal(
    title=str,               # Required
    inputs=[...],            # TextInput, Checkbox, CheckboxGroup, RadioGroup, FileUpload
    callback=async_fn,       # callback(interaction, values), sync or async
    timeout=None,
    view_id=None,            # If set, dispatches MODAL_SUBMITTED action
    custom_id=None,          # Forwarded to discord.ui.Modal; omit to auto-generate
)
  • inputs accepts any combination of CascadeUI input wrappers (TextInput, Checkbox, CheckboxGroup, RadioGroup, FileUpload) or raw discord.py inputs: a discord.ui.TextInput, or a discord.ui.Label around any modal input, which is how a select goes into a modal. A discord.ui.TextDisplay among them is shown as text and submits nothing. A button, a select passed without its Label, or any other component that submits no value raises TypeError at construction, since Discord would refuse the modal when it opened; modal.add_item() refuses the button and the bare select too. Discord caps a modal at 5 top-level inputs; labels must be distinct because each input's custom_id derives from its label (duplicates raise at construction).
  • Validators are read from each input's validators list and collected internally. On failure, an ephemeral error message is sent and the callback is skipped. Each validator receives whatever its input submits: str for text, bool for a checkbox, list[str] for a checkbox group, list[discord.Attachment] for an upload.
  • view_id -- links the modal to a view's state. A MODAL_SUBMITTED action is dispatched before the callback runs.
  • A modal opened from a button on a view answers its submission with that view's re-render, in one request, when the callback re-renders it, as a click is answered. The callback then replies with respond() or interaction.followup, since the response slot is used; interaction.response raises InteractionResponded, as it does after a click's re-render. A view reacting to MODAL_SUBMITTED re-renders before the callback runs and edits its message, leaving the answer to the callback. If the view has closed by the time the callback runs, the closed-session notice answers instead.
  • If no callback is provided, the interaction is deferred automatically. A callback that cannot accept (interaction, values) raises TypeError at construction, since every submission delivers the collected values.
  • The constructor's signature is closed: an unrecognized keyword raises TypeError naming it, rather than being discarded silently. on_submit= is the method discord.py subclasses override and the natural wrong guess for callback=, and a modal with no handler set acknowledges every submission and runs nothing.
  • auto_defer_delay (class attribute, default 2.5) -- the ack backstop in seconds. Modal.on_submit arms an auto-defer timer across the whole submission (the access check, the validators, and the callback), so a slow validator or a raising handler cannot leave the interaction unacknowledged. Raise it on a subclass with a slow async validator, but keep it under 3.0: the timer defers after this delay, so a value at or past Discord's 3-second acknowledgment deadline can never land. It validates at class-definition time (a positive number under 3.0).

Responding from on_submit: an override that sends its own reply should use await self.respond(interaction, ...) rather than interaction.response.send_message(). Like the view helper, Modal.respond() is is_done()-aware: it falls back to a followup when the ack backstop has already fired, so a reply sent after a slow validator does not raise InteractionResponded.

Opening modals from CascadeUI callbacks: use self.open_modal(interaction, modal) instead of interaction.response.send_modal(). It handles the case where auto-defer has already consumed the response slot by sending an ephemeral fallback.

await modal.submit(interaction, values) -> bool

Drives a submission offline, through the pipeline a real one takes. The offline-testing surface for modals, alongside on_load(), validate(), and stub_client().

values is keyed by either an input's label ("Emoji") or its derived custom_id ("input_emoji"). The custom_id is the identity, since that is how Modal.inputs and the values mapping your callback receives are both keyed; the label is an alias on top of it. A field left out keeps whatever value it holds, so a test supplies only the fields it cares about.

A key naming no input raises ValueError listing the ones that exist.

A raw escape-hatch input carries whatever custom_id its author chose, so one can equal another input's label. The custom_id wins: it is that input's identity and often its only name, while the input whose label lost the alias still resolves by its own custom_id. An alias comes from a ui.Label's text or a CascadeUI wrapper's own label, never from a raw component's deprecated label property.

A RuntimeError means the component has no storage behind its value property, so discord.py moved the attribute; or the submit pipeline never ran, leaving no verdict to return. The verdict is what on_submit records as it runs, so an override must await super().on_submit(interaction); one that does not is refused rather than reported as a rejection. Either is raised rather than submitted, since the submission would otherwise run against defaults and report success.

Returns True when the submission was accepted and the callback ran, False when a validator rejected it. A rejection is not an error: it is usually the case under test.

modal = screen.build_rename_modal()

assert await modal.submit(interaction, {"Name": "ab"}) is False   # too short
assert await modal.submit(interaction, {"Name": "Ada"}) is True

This assigns the values and then calls on_submit itself, so there is no second implementation to drift from the first: the validators, the values_by_input write-back, the MODAL_SUBMITTED dispatch, and the ack backstop all run exactly as they do for a real submission. Reaching past it to call the stored callback directly skips all of them, and the validator pass is the one that matters -- a test written that way succeeds against input the modal would have rejected.

It starts at on_submit, so a subclass overriding interaction_check does not see it: that gate runs in discord.py's dispatch, above the pipeline this drives. Test an access gate by calling it directly.


Checkbox

Wraps discord.ui.Checkbox with a stable custom_id derived from the label.

Checkbox(
    label=str,               # Required
    default=False,
    validators=None,
    description=None,        # Optional: ui.Label.description
)

After submit: .value -> bool.


CheckboxGroup

Wraps discord.ui.CheckboxGroup with stable custom_id and dict shorthand for options.

CheckboxGroup(
    label=str,               # Required
    options=[{"label": str, "value": str, "default": bool}, ...],
    required=True,
    min_values=None,         # Discord defaults to 0 when omitted
    max_values=None,         # Discord defaults to 1 when omitted
    validators=None,
    description=None,        # Optional: ui.Label.description
)

Options accept dict shorthand or native discord.CheckboxGroupOption instances. Discord accepts 1-10 options; an out-of-range count raises a directed ValueError at construction. min_values accepts 0-10 and max_values accepts 1-10, likewise raising at construction when out of range. After submit: .values -> list[str].


RadioGroup

Wraps discord.ui.RadioGroup with stable custom_id and dict shorthand for options.

RadioGroup(
    label=str,               # Required
    options=[{"label": str, "value": str, "default": bool}, ...],
    required=True,
    validators=None,
    description=None,        # Optional: ui.Label.description
)

Same dict shorthand as CheckboxGroup. Discord requires 2-10 options; an out-of-range count raises a directed ValueError at construction. After submit: .value -> str.


FileUpload

Wraps discord.ui.FileUpload with stable custom_id.

FileUpload(
    label=str,               # Required
    required=True,
    min_values=None,         # Discord defaults to 0 when omitted
    max_values=None,         # Discord defaults to 1 when omitted
    validators=None,
    description=None,        # Optional: ui.Label.description
)

min_values accepts 0-10 and max_values accepts 1-10; an out-of-range bound raises a directed ValueError at construction. After submit: .values -> list[discord.Attachment].

Ephemeral attachment URLs

discord.Attachment URLs expire. Read attachment data in the modal callback -- do not store attachments in the state store.


V2 Helpers

Convenience functions for building V2 component trees. All return standard discord.py V2 components -- no custom classes needed. These work inside StatefulLayoutView and its subclasses.

card(*children, color=None, spoiler=False)

Creates a Container with children and an optional accent color. Strings are automatically wrapped in TextDisplay. Pass spoiler=True to hide the entire container behind a spoiler overlay.

Raises ValueError when a child is a Container. Discord forbids a Container inside a Container, so card(heading, alert(...)) is never legal -- place the alert as a sibling of the card. alert(), card() and stats_card() are the builders that produce one.

color takes a discord.Colour or a plain int, so color=0x5865F2 is equivalent to color=discord.Colour(0x5865F2). A value outside 0x000000-0xFFFFFF, a bool, or a non-colour type raises at construction, naming the builder and the parameter, rather than reaching Discord.

card(
    "## Title",              # Strings become TextDisplay automatically
    TextDisplay("Content"),  # V2 components pass through as-is
    divider(),
    color=discord.Color.blurple(),
)

key_value(data)

Converts a dict to a formatted TextDisplay.

key_value({"Status": "Online", "Users": "42"})
# Renders: **Status:** Online\n**Users:** 42

action_section(text, *, label, callback, emoji=None, style=secondary, custom_id=None, disabled=False)

Creates a Section with text and a StatefulButton accessory. The callback takes the interaction alone; a callback that demands a second argument raises TypeError at construction. Pass disabled=True to render the button greyed out and non-interactive. Pass custom_id= inside a PersistentLayoutView, where auto-generated ids do not survive a restart.

action_section(
    "Click to refresh",
    label="Refresh",
    callback=self.refresh,
    emoji="\U0001f504",
)

toggle_section(text, *, active, callback, labels=("Enabled", "Disabled"), emoji=None, custom_id=None, disabled=False)

Creates a Section with a green/red toggle button. labels sets the (active, inactive) button text -- pass ("On", "Off") to relabel. emoji adds a button emoji. Pass disabled=True to render the button greyed out and non-interactive. Pass custom_id= inside a PersistentLayoutView, where auto-generated ids do not survive a restart.

The callback takes (interaction) or (interaction, active); the two-parameter form receives the state the click asks for (the flip of the rendered active), matching toggle_button and cycle_button. A callback that can accept neither shape raises TypeError at construction. The second click of a double-click is dropped rather than flipping the toggle back, since both clicks come from the same render.

toggle_section(
    "**Dark Mode**\nEnable dark theme",
    active=self.dark_mode,
    callback=self.toggle_dark,
)

alert(message, *, level="info")

A colored status container. Levels: "success" (green), "warning" (gold), "error" (red), "info" (blue).

alert("Settings saved!", level="success")

divider(large=False)

A Separator with SeparatorSpacing.small (default) or SeparatorSpacing.large.

gap(large=False)

A Separator without a visible line. SeparatorSpacing.small (default) or SeparatorSpacing.large.

image_section(text, *more_text, url, description=None, spoiler=False)

A Section with a Thumbnail image accessory. description sets the thumbnail's alt text (up to 1024 chars); spoiler=True hides the thumbnail behind a spoiler. url accepts a MediaInput: a URL string, a discord.File, or any object with a string .url such as member.display_avatar.

image_section("User avatar", url="https://example.com/avatar.png")

A Section with a link-style Button accessory that opens a URL. Completes the *_section family (action / image / link); link buttons carry no callback because the platform handles navigation directly.

link_section(
    "Full documentation is on GitHub Pages.",
    label="Open Docs",
    url="https://hollowthesilver.github.io/CascadeUI/",
)

confirm_section(text, *, on_confirm, on_cancel, confirm_label="Confirm", cancel_label="Cancel", confirm_emoji="✅", cancel_emoji="❌", confirm_style=ButtonStyle.success, cancel_style=ButtonStyle.danger, custom_id=None)

A confirm/cancel prompt. Returns a [TextDisplay, ActionRow] list rather than a single component: the prompt text plus the paired button row. Splat it into card(...) or add it directly to a view. Both callbacks take the interaction alone; one that demands a second argument raises TypeError at construction. The prompt takes one answer: once one button's callback runs, a click on either button sent before its result was on screen is dropped. Pass custom_id= inside a PersistentLayoutView, where auto-generated ids do not survive a restart.

The style defaults suit a constructive prompt. A destructive one wants them swapped (confirm_style=ButtonStyle.danger, cancel_style=ButtonStyle.secondary), or the button that deletes renders green beside a red one that does nothing.

Returning a list is also why this builder takes no id=: there is no single component for one to name. Assign .id on the returned components if you need them addressable.

card(
    "## Reset settings?",
    *confirm_section(
        "This cannot be undone.",
        on_confirm=self._do_reset,
        on_cancel=self._cancel,
    ),
)

gallery(*media, descriptions=None, spoilers=None)

A MediaGallery from one or more images passed as positional arguments (not a list). Each item is a MediaInput -- a URL string, a discord.File, or any object with a string .url; descriptions is an optional parallel sequence of alt-text strings.

gallery(
    "https://example.com/img1.png",
    "https://example.com/img2.png",
)

emoji_grid(rows, cols, *, fill="⬛", row_labels=None, col_labels=None, corner=None, cell_sep=" ")

Returns an EmojiGrid -- a live subclass of discord.ui.TextDisplay that renders a rectangular cell grid with optional axis labels. Supports assignment by int index, (row, col) tuple, or iterable of keys. Provides fill_rect(top_left, bottom_right, value) and clear(). Plugs directly into card() and Container().

Axis label presets: "alpha" (regional indicator glyphs, max 26), "numeric" (keycap emoji, max 10). Pass a list of custom emoji for other label styles.

grid = emoji_grid(10, 10, fill="🟦", row_labels="alpha", col_labels="numeric")
grid[3, 5] = "💥"  # Hit at row 3, column 5

button_grid(rows, cols, cell_factory)

Packs a (row, col) -> Button factory into a list of ActionRow components, enforcing Discord's 5x5 LayoutView component limit.

rows = button_grid(3, 3, lambda r, c: StatefulButton(
    label=board[r][c], callback=self.on_cell_click,
))
for row in rows:
    self.add_item(row)

choice_row(options, *, on_select, selected=None, multi=False, disabled=False, allow_reselect=False, button_threshold=5, active_style=primary, inactive_style=secondary, placeholder=None, custom_id="choice")

A single-select (or multi-select) "choose one/any" control. Renders a segmented button ActionRow at or below button_threshold options (active = highlighted, and disabled in single-select), or a StatefulSelect dropdown for 6-25 options. Raises ValueError past 25. on_select takes (interaction, value) and receives the picked value (single) or the list of selected values (multi); a callback that cannot accept both raises TypeError at construction. The builder handles the string round-trip Discord forces on select option values, so the callback always gets the real Python value. disabled=True greys out the whole control (every button, or the dropdown) for a read-only or locked state. allow_reselect=True keeps the active single-select option clickable so a re-pick fires on_select again (default False makes a re-pick a no-op in both button and dropdown forms); it is ignored in multi-select, where active options already toggle. In multi-select the second click of a double-click on one option is dropped rather than toggling it back.

choice_row(
    {"Easy": Difficulty.EASY, "Hard": Difficulty.HARD},
    selected=self.difficulty,
    on_select=self._set_difficulty,   # async (interaction, value) -> None
)

options is a {label: value} dict or a sequence of Choice. multi=True reads selected as a collection of active values rather than one, turns the buttons into toggles, and delivers a list to on_select. Values need not be hashable -- a Choice.value may be any Python object, including a list or a dict. active_style / inactive_style set the button colors (button form only; dropdowns have no per-option style), and placeholder sets the dropdown's placeholder text. custom_id matters only inside a PersistentLayoutView, where each option's id is {custom_id}_{n}: give each control in the view its own, and keep its options in a fixed order, since those ids name positions. Raises ValueError for an empty options, more than 25 options, or a button_threshold outside 0-5; raises TypeError if on_select is not callable.

Choice

The rich option form for choice_row (a NamedTuple). Use it instead of a plain dict entry when an option needs an emoji or a per-option dropdown description.

Choice(label="Goals", value=Event.GOAL, emoji="⚽", description="Match goals")

label and value are required; emoji and description default to None. description renders on the dropdown form and is ignored when the control renders as buttons.

toggle_button(*, active, on_toggle, labels=("Enabled", "Disabled"), emoji=None, custom_id=None)

A standalone boolean toggle button -- the ActionRow form of toggle_section (no accompanying text). Renders green when active, relabels between the two labels on each click, and calls on_toggle with the new state; the second click of a double-click is dropped rather than flipping it back. on_toggle takes (interaction, active); a callback that cannot accept both raises TypeError at construction. Pass custom_id= inside a PersistentLayoutView, where auto-generated ids do not survive a restart.

ActionRow(toggle_button(active=self.notify, on_toggle=self._set_notify))

button_row(buttons, *, style=secondary, emoji=None, custom_id=None)

An ActionRow built from a {label: callback} mapping: one StatefulButton per entry, sharing style and emoji. Each callback takes the interaction alone; one that demands a second argument raises TypeError at construction. Raises ValueError on an empty mapping or more than Discord's five buttons per row. custom_id matters only inside a PersistentLayoutView, where it is a base suffixed per button ({custom_id}_0, {custom_id}_1, ...) and ids that survive a restart are required; outside one, each button's id is derived from what it does, as for any other button.

button_row({"Save": self._save, "Reset": self._reset}, style=discord.ButtonStyle.primary)

cycle_button(*, values, on_change, labels=None, style=secondary, emoji=None, start=0, custom_id=None)

A button that cycles through a fixed list of values on each click, advancing (and wrapping) the index before calling on_change with the new value. The second click of a double-click is dropped rather than skipping a value. Use it when a setting has three or more options but a full select is overkill -- a single "Preset" button cycling ["Low", "Medium", "High"] instead of three toggles. on_change takes (interaction, value); a callback that cannot accept both raises TypeError at construction. labels defaults to str(value) per entry; start is the initial index. Pass custom_id= inside a PersistentLayoutView, where auto-generated ids do not survive a restart.

cycle_button(
    values=["Low", "Medium", "High"],
    on_change=self._set_preset,   # async (interaction, value) -> None
)

tab_nav(tabs, *, active=None, active_style=primary, inactive_style=secondary, custom_id=None)

An ActionRow of tab buttons for inner-view navigation -- a lighter alternative to TabLayoutView. tabs maps each label to a callback; each callback takes the interaction alone, and one that demands a second argument raises TypeError at construction. The active tab renders in active_style, the rest in inactive_style. Pass custom_id= inside a PersistentLayoutView, where auto-generated ids do not survive a restart.

tab_nav(
    {"Overview": self._show_overview, "Settings": self._show_settings},
    active="Overview",
)

stats_card(title, stats, *, color=None, footer=None, spoiler=False)

A titled Container rendering a {label: value} dict as key-value lines, with an optional footer. Reads the active theme's accent_colour when color=None, the same as card.

stats_card("Match Stats", {"Goals": 3, "Shots": 11}, footer="Updated live")

progress_bar(value, max_value, *, width=20, filled="█", empty="░", show_percent=True)

A text progress bar rendered into a TextDisplay. width is the bar length in characters; filled / empty are the cell glyphs; show_percent appends the percentage.

progress_bar(7, 10)   # [██████████████░░░░░░] 70%

render_progress(value, max_value, *, width=20, filled="█", empty="░", show_percent=True)

The same bar as a str, for inlining into text a caller is already building: a leaderboard row's secondary line, a key_value cell, a stats_card field. progress_bar composes this into a TextDisplay, so the two never render differently.

f"{name} {render_progress(wins, games, width=6)}"   # Ada [████░░] 66%

Convenience Buttons

Preset-style buttons. All but LinkButton extend StatefulButton:

  • PrimaryButton -- ButtonStyle.primary
  • SecondaryButton -- ButtonStyle.secondary
  • SuccessButton -- ButtonStyle.success
  • DangerButton -- ButtonStyle.danger
  • LinkButton -- ButtonStyle.link. Wraps discord.ui.Button directly, since Discord opens the URL client-side and no interaction is ever dispatched. It takes no callback and no owner_only. An empty or whitespace url raises ValueError and a non-string one raises TypeError at construction: Discord would otherwise refuse the whole message with a form error naming no component. link_section is held to the same rule
  • ToggleButton -- Toggles between two states on click. The callback takes (interaction) or (interaction, toggled); the two-parameter form receives the post-flip state, matching toggle_button and toggle_section. A callback that can accept neither shape raises TypeError at construction. The second click of a double-click is dropped rather than flipping it back

V2 Composite Components

Stateful helpers that live inside a StatefulLayoutView and hold their own state across interactions, rather than owning the message (a view) or returning a one-shot tree (a builder).

PaginatedRegion

Pages one slice of a host view's tree while the host owns the rest. The V2 sibling of PaginationControls. Each instance holds its own page index, so two regions can live in one view (give them distinct key values).

PaginatedRegion(
    *,
    items=None,    # initial item list; or set later via .items =
    per_page=10,   # items per page (positive int); per_page=1 is a carousel
    key="page",    # custom_id disambiguator; distinct per region in one view
)

items holds the full list, page_items exposes the current slice, and controls(view) captures the host and returns the nav row. The captured host is readable afterwards as host (read-only, None before the first capture), which is what a hook needing the view's own data reaches for. Drive all three from the host's build_ui() or on_load():

def build_ui(self):
    self.clear_items()
    self.pager.items = self.tasks
    rows = [action_section(t.title, label="Open", callback=self._open(t))
            for t in self.pager.page_items]
    self.add_item(card("## Tasks", *rows, *self.pager.controls(self)))

controls(view) returns [] on a single page and one nav ActionRow otherwise. A click updates the index and re-runs whichever render path the host provides: its build_ui(), a TabLayoutView's tab refresh, or reload(). The new slice renders before the refresh. First/last jump buttons and a go-to-page modal appear once the page count reaches jump_threshold. Customization mirrors PaginatedLayoutView: subclass and override the {first,prev,indicator,next,last}_button_{label,emoji,style} class attributes, indicator_button_format, or jump_threshold.

control_buttons(view, *, compact=False) -> list

The button-level counterpart to controls(): the same wired prev/next/jump buttons, returned as a bare list instead of an ActionRow, so a node-tight host packs them into a row it owns. Mirrors the make_back_button (primitive) / make_nav_row (wrapper) split -- controls() is the convenience wrapper, this is the primitive underneath it. Returns [] on a single page.

compact=True returns three buttons (prev, go-to-page, next), dropping first/last and forcing the clickable go-to middle. The compact set fuses with Back + Exit inside one five-button ActionRow (3 + 2 = 5), the layout a per_page=1 carousel near the 40-component message cap needs. compact= is also accepted on controls() for a compact pager in its own row. The full set is up to five buttons and is meant for a row of its own; fusing it with other buttons overflows the per-row budget.

def build_ui(self):
    self.clear_items()
    self.add_item(card("## Fight", *self._render_markets()))
    self.add_item(ActionRow(
        *self.pager.control_buttons(self, compact=True),
        self.make_back_button(),
        self.make_exit_button(),
    ))

async on_page_changed(page) -> None

Override hook. Called after the page index updates, before the refresh. Default is a no-op. Use for analytics, async prefetch, or per-page validation. Read self.host for the view the region renders into, which is what a prefetch needs; it is None until the host's first render. When the re-render does not land the region stays on the page it showed and the hook is not called again.

await show_page(index, *, notify=True)

Jumps to a zero-based page index, fires on_page_changed, and re-renders the host: the async counterpart to a nav-button click, for a programmatic jump (a search hit, a "find me" button, landing on the page holding a row the user just created). Raises RuntimeError, moving nothing, when the region is not attached yet, since controls(view) is what gives it a host to re-render; seek with set_page() before then. notify=False skips on_page_changed, for a jump the host makes on its own rather than one a user asked for: a return to the first page after inactivity, run by the timer that on_page_changed re-arms, would otherwise cancel itself. When the edit does not land, the cursor goes back to the page on screen either way. The render waits for a reload or a state render the host is already running in another task, unless it runs inside that one: in the host's on_load() or on_state_changed(), or in a task either of them started, while that run lasts, such as asyncio.gather(region.show_page(n)). A host with neither build_ui nor tabs renders by running on_load() again, so inside its on_load() the call raises RuntimeError and the page goes back; call set_page() before the tree is built there instead.

set_page(index), page, page_count

set_page(index) moves the cursor to a zero-based page index without re-rendering: use it before the region is attached, such as restoring a carried-over page in restore_nav_state. Reach for show_page() when the jump should also update the message. page reads the current zero-based index; page_count reads the total page count (minimum 1). Use them when the host renders a "page X of Y" line or gates a control on the current position.

Collapsible(*, label, reveal, summary=None, expanded_label=None, style=secondary, expanded_style=secondary, emoji=None, expanded_emoji=None, expanded=False, trigger_first=True, key="collapsible")

A trigger button that toggles an inline region of revealed content (the disclosure/expander pattern). Holds its own collapsed/expanded state. reveal is a zero-argument synchronous callable returning the revealed component(s); the host loads any async data in on_load() and reveal reads it synchronously. A reveal (or summary) that is async or requires arguments raises TypeError at construction, since both run bare on every render. expanded_label and expanded_emoji default to label and emoji -- the trigger keeps its collapsed text and icon while expanded unless you set them.

self.picker = Collapsible(
    label="Edit Leagues",
    expanded_label="Done",
    reveal=lambda: choice_row(LEAGUES, selected=self.league, on_select=self._pick),
)

def build_ui(self):
    self.clear_items()
    for item in self.picker.render(self):
        self.add_item(item)

render(view) returns [trigger] collapsed, or the trigger plus reveal() (ordered by trigger_first) expanded. A click flips the state, fires on_toggle, and re-runs the host's render path (the same build_ui/reload seam PaginatedRegion uses). As with show_page(), that render waits for a reload or a state render the host is already running, and renders nothing when the host closed while it waited. The second click of a double-click is dropped rather than closing what the first opened. The trigger relabels/restyles via expanded_label / expanded_style / expanded_emoji. Two collapsibles in one view need distinct key= values.

By default the trigger is a bare ActionRow(button). Pass summary (the text, or a zero-argument synchronous callable read on every render, like reveal, when the text changes) to fuse the trigger into an action_section instead: a Section carrying the summary text with the trigger button as its accessory. This is the shape a card-based disclosure wants, where the Edit button sits beside its summary line rather than in a row of its own. The whole disclosure then splats into one card(...):

self.rep = Collapsible(
    label="Edit", expanded_label="Done", emoji="✏️",
    summary=lambda: f"Flagged beside your name: **{self.represented_name}**.",
    reveal=lambda: ActionRow(self._represented_select()),
    key="representation",
)

def build_ui(self):
    self.clear_items()
    self.add_item(card("### 🏳️ Representation", *self.rep.render(self)))

When summary returns an empty value (data not loaded yet), the trigger falls back to the bare button rather than emitting an empty Section.

  • expand() / collapse() set the state programmatically; expanded reads it.
  • The host owns collapse policy: call collapse() after a revealed action, or leave it open.

async on_toggle(expanded) -> None

Override hook. Called after the state flips, before the re-render. Default is a no-op. Use to fetch async data when expanded, log toggle events, or validate on every open/close. Read self.host for the view the collapsible renders into; it is None until the host's first render captures it. When the re-render does not land the state flips back to match the screen and the hook is not called again.


V1 Composite Components

These extend CompositeComponent and use row-based layout. They work with StatefulView but are not compatible with V2 views.

ConfirmationButtons

ConfirmationButtons(on_confirm=async_fn, on_cancel=async_fn)
buttons.add_to_view(view)

Both callbacks take the interaction alone; one that demands a second argument raises TypeError at construction. The prompt takes one answer: once one button's callback runs, a click on either button sent before its result was on screen is dropped.

PaginationControls

PaginationControls(page_count=int, on_page_change=async_fn)
controls.add_to_view(view)

on_page_change takes (interaction, page) and receives the new zero-based page; a callback that cannot accept both raises TypeError at construction. The callback draws the page, so when it raises, the controls return to the page they left before the exception propagates.

ToggleGroup

ToggleGroup(options=["A", "B", "C"], on_select=async_fn, default="B")
group.add_to_view(view)

on_select takes (interaction, value) and receives the selected option; a callback that cannot accept both raises TypeError at construction.

ProgressBar

bar = ProgressBar(total=100, width=20, fill_char="█", empty_char="░")
bar.render(current)  # Returns string like "████████░░░░░░░░░░░░ 40%"

Wrappers

All wrappers attempt to use interaction.response internally, with an is_done() fallback for auto-defer compatibility. Wrapped callbacks should use self.respond(interaction, ...) for any replies -- it handles the response/followup routing automatically.

with_loading_state(component, loading_label="Loading...", loading_emoji=None)

Shows a loading indicator while the callback runs. The component is disabled and its label is replaced during execution.

with_confirmation(component, title="Confirm Action", message="Are you sure?", ...)

Adds an ephemeral yes/no prompt before the callback runs. The prompt takes one answer: a click sent before the first answer landed, on either button, is dropped. Additional parameters:

  • color -- embed color (default: yellow)
  • confirm_label / cancel_label -- button labels
  • confirm_style / cancel_style -- button styles
  • confirmed_message / cancelled_message -- text shown after choice
  • on_cancel -- optional async callback on cancel
  • timeout -- prompt timeout in seconds (default: 60)

with_cooldown(component, seconds=5, message=None, scope="user", key=None)

Enforces a cooldown between clicks. Expired entries are automatically cleaned up.

This is the per-user spam guard: it throttles one expensive control for one clicker. refresh_cooldown_ms is the wrong tool for that job -- it paces a whole view's background re-renders and exempts edits a click asked for.

  • seconds -- cooldown duration; fractional values work, and the rejection notice reports the remainder to one decimal (0.2s)
  • message -- custom message (use {remaining} for time left)
  • scope -- "user" (default), "guild", "user_guild", or "global"; the same four-value grammar as state_scope and instance_scope
  • key -- names the deadline on the owning view. Defaults to the custom_id when you pass one, otherwise to the wrapped callback's qualified name; both are stable across rebuilds. Pass one when neither names this control alone: several components wired to one callback, or callables minted per item (factory closures, lambdas, partials), which all share a qualified name

On a rejected click, with_cooldown logs one warning per view class when two or more controls share a defaulted key while calling different callbacks. Deliberate sharing and any explicit key= or custom_id= stay silent.

Deadlines live on the owning view, not in the wrap call, so wrapping a component your build method constructs fresh each render works -- the deadline outlives the component it was recorded on. They last as long as the view instance: a fresh open, or a pop() that reconstructs the view, starts clean.

Wrappers compose. with_cooldown(with_confirmation(button)) keys its deadline on the callback you wrote, not on with_confirmation's closure, so stacking with_loading_state, with_confirmation, and with_cooldown on one component keeps their state apart.


Utilities

fetch_as_file(url, filename, *, session=None, spoiler=False, description=None, max_bytes=100 * 1024 * 1024)

Fetches url into an in-memory discord.File, for the attachment:// references the V2 media builders (gallery, image_section, file_attachment) emit. Pass one aiohttp.ClientSession across several fetches in a callback; a call without one opens and closes its own. See Local file attachments.

An error status raises aiohttp.ClientResponseError rather than returning the error page as the file. A body larger than max_bytes raises ValueError; it is counted as it arrives, and the read stops once the count passes the limit, so a larger body is never read in full. The default, 100 MiB, is the most Discord accepts from a bot in any server; pass guild.filesize_limit for one server's own limit, or None for no limit. A max_bytes that is not an int raises TypeError, and one below 1 raises ValueError, both before any request.

slugify(text)

Converts display strings to safe custom_id fragments.

slugify("Color Roles")    # "color_roles"
slugify("He/Him")         # "he_him"
slugify("Tickets #1")     # "tickets_1"

@cascade_component(component_id=None)

Decorates a view method so that calling it dispatches COMPONENT_INTERACTION before the body runs. The payload carries component_id, view_id, user_id, and handler. Reach for it to record an interaction a plain callback would not dispatch on its own.

from cascadeui import cascade_component

class BoardView(StatefulLayoutView):
    @cascade_component("reroll")
    async def reroll(self, interaction):
        self._board = new_board()
        self.build_ui()      # compose the new board
        await self.refresh()  # then ship it

The decorated function is a method: it takes self and dispatches through the view it belongs to. When component_id is omitted, the function's __name__ is used.

The build_ui() line is load-bearing. refresh() sends the tree the view already holds and composes nothing, so mutating state and calling only refresh() ships the pre-mutation render. Worse, an unchanged tree hashes the same, so the render-hash skip suppresses the edit and the click produces no visible result at all. The dispatch also happens before the body runs, so a view driving its render from on_state_changed rebuilds from the old state; compose in the body as above rather than relying on the dispatch.

register_component(name, component_class) / get_component(name)

The V1 composition registry. register_component stores a component class under a name; get_component returns that class, or None when the name was never registered.

from cascadeui import register_component, get_component

register_component("confirm_row", ConfirmationButtons)

cls = get_component("confirm_row")
cls(on_confirm=save, on_cancel=discard).add_to_view(view)

This registry holds classes and is unrelated to @cascade_component, which dispatches actions. Registering under a name that already exists replaces the entry.