API: Components¶
Type Aliases¶
EmojiInput¶
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¶
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¶
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¶
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¶
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¶
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¶
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:
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--StatefulSelectsubclass that also accepts option dicts ({"label": ..., "value": ...}) and converts them.StatefulSelectitself takesSelectOptioninstances only and rejects anything else at constructionRoleSelect-- extendsdiscord.ui.RoleSelectChannelSelect-- extendsdiscord.ui.ChannelSelectUserSelect-- extendsdiscord.ui.UserSelectMentionableSelect-- extendsdiscord.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
intIDs (RoleSelect / UserSelect / ChannelSelect only --MentionableSelectrejects bare ints because the type cannot be inferred). - Discord.py objects with
.idattributes (Member, User, Role, GuildChannel). - Pre-built
discord.SelectDefaultValueinstances (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.
Modal¶
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
)
inputsaccepts any combination of CascadeUI input wrappers (TextInput,Checkbox,CheckboxGroup,RadioGroup,FileUpload) or raw discord.py inputs: adiscord.ui.TextInput, or adiscord.ui.Labelaround any modal input, which is how a select goes into a modal. Adiscord.ui.TextDisplayamong them is shown as text and submits nothing. A button, a select passed without itsLabel, or any other component that submits no value raisesTypeErrorat 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'scustom_idderives from its label (duplicates raise at construction).- Validators are read from each input's
validatorslist and collected internally. On failure, an ephemeral error message is sent and the callback is skipped. Each validator receives whatever its input submits:strfor text,boolfor a checkbox,list[str]for a checkbox group,list[discord.Attachment]for an upload. view_id-- links the modal to a view's state. AMODAL_SUBMITTEDaction 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()orinteraction.followup, since the response slot is used;interaction.responseraisesInteractionResponded, as it does after a click's re-render. A view reacting toMODAL_SUBMITTEDre-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
callbackis provided, the interaction is deferred automatically. Acallbackthat cannot accept(interaction, values)raisesTypeErrorat construction, since every submission delivers the collected values. - The constructor's signature is closed: an unrecognized keyword raises
TypeErrornaming it, rather than being discarded silently.on_submit=is the method discord.py subclasses override and the natural wrong guess forcallback=, and a modal with no handler set acknowledges every submission and runs nothing. auto_defer_delay(class attribute, default2.5) -- the ack backstop in seconds.Modal.on_submitarms 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 under3.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 under3.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.
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.
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).
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.
link_section(text, *, label, url, emoji=None, disabled=False)¶
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.
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.
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.
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.
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.
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.
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.
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.
Convenience Buttons¶
Preset-style buttons. All but LinkButton extend StatefulButton:
PrimaryButton--ButtonStyle.primarySecondaryButton--ButtonStyle.secondarySuccessButton--ButtonStyle.successDangerButton--ButtonStyle.dangerLinkButton--ButtonStyle.link. Wrapsdiscord.ui.Buttondirectly, since Discord opens the URL client-side and no interaction is ever dispatched. It takes nocallbackand noowner_only. An empty or whitespaceurlraisesValueErrorand a non-string one raisesTypeErrorat construction: Discord would otherwise refuse the whole message with a form error naming no component.link_sectionis held to the same ruleToggleButton-- Toggles between two states on click. The callback takes(interaction)or(interaction, toggled); the two-parameter form receives the post-flip state, matchingtoggle_buttonandtoggle_section. A callback that can accept neither shape raisesTypeErrorat 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;expandedreads 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¶
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¶
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¶
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 labelsconfirm_style/cancel_style-- button stylesconfirmed_message/cancelled_message-- text shown after choiceon_cancel-- optional async callback on canceltimeout-- 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 asstate_scopeandinstance_scopekey-- names the deadline on the owning view. Defaults to thecustom_idwhen 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.