How to create Discord views with buttons and menus in custom commands bot
Build interactive Discord messages in a custom command — buttons, select menus, click handlers, per-user locking, timeouts and live button updates.
Md Shahriyar Alam
2 years ago
A view is a group of components — buttons, select menus — that work together on one Discord message and share state.
You write a class. Each decorated method becomes a component, and the method body is what runs when someone clicks it. The bot handles sending, matching the click back to your method, and cleaning up when it ends.
Everything below goes in the code box of a slash command or a text command.
Create a view
class AgreeView(View):
def __init__(self):
self.agree = False # start with no answer
@button(label="Yes", style="success")
async def yes(self, interaction, button):
await defer(interaction, action="update")
respond_interaction(interaction, empty=True)
self.agree = True
self.stop() # ends the view
@button(label="No", style="danger")
async def no(self, interaction, button):
await defer(interaction, action="update")
respond_interaction(interaction, components=None, content="Cancelled")
self.stop()
view = AgreeView()
respond_interaction(interaction, content="Do you agree?", components=view)
exit_code = await view.wait(scope=scope) # wait until the view stops
if exit_code == "timeout":
set_error("Timeout")After wait() returns, view.agree holds the answer and you can branch on
it anywhere in the rest of your command.
The four things that make it work
__init__ sets up your own state. Anything you store on self survives
until the view ends. Do not call super().__init__().
@button(...) turns a method into a button. The method name does not
matter; the decorator does.
self.stop() ends the view immediately and makes wait() return
"stopped".
await view.wait(scope=scope) pauses your command until the view ends.
scope is a value the bot gives you — pass it exactly as written.
Watch the class name.
view = AgreeView()must match the class you defined. A mismatched name is the most common mistake here and gives you aNameError.
Handling a click
Every callback receives three things:
async def yes(self, interaction, button):| Name | What it is |
|---|---|
self | the view, with all your state on it |
interaction | the click — who clicked, and how you answer them |
button | the component that was clicked |
You must answer the interaction
Discord gives you 3 seconds to respond to a click. Miss it and the member sees a red "This interaction failed", even though your code ran fine.
Update the existing message:
await defer(interaction, action="update")
respond_interaction(interaction, content="Saved!")Send a private reply only that person sees:
await defer(interaction, action="create", ephemeral=True)
respond_interaction(interaction, content="That is not for you.")Acknowledge and change nothing:
await defer(interaction, action="update")
respond_interaction(interaction, empty=True)Remove the buttons:
respond_interaction(interaction, components=None, content="Cancelled")Button options
@button(
label="Confirm",
custom_id="confirm",
style="success",
emoji="✅",
row=1,
is_disabled=False,
hidden=False,
)
async def confirm(self, interaction, button):
...| Option | Default | What it does |
|---|---|---|
label | required | the text on the button |
style | "primary" | primary, secondary, success, danger |
custom_id | random | needed only if you want to update this button later |
emoji | none | unicode, or a custom one as <:name:id> |
row | 1 | which row it sits on, 1 to 5 |
is_disabled | False | greyed out and unclickable |
hidden | False | not sent at all — useful for a button you reveal later |
url | none | makes it a link button |
Styles
primary— blurple, for the main actionsecondary— grey, for everything elsesuccess— greendanger— red, for anything destructive
Link buttons
@button(label="Open the docs", url="https://ccbot.app")
async def docs(self, interaction, button):
pass # never called; Discord handles link buttons itselfPassing your own data
Extra keyword arguments are stored on the button and handed back to you:
@button(label="", row=1, index=0)
async def one(self, interaction, button):
self.board[button.metadata["index"]] = interaction.user.idThat is how a nine-button grid can share a single handler.
Select menus
Four kinds, each a decorator like @button.
A menu of your own options
@text_menu(
options=[
TextOption(label="Red", value="red", description="A warm colour"),
TextOption(label="Blue", value="blue", emoji="🔵"),
],
placeholder="Pick a colour",
custom_id="colour",
row=1,
)
async def pick_colour(self, interaction, menu):
chosen = interaction.values[0]
await defer(interaction, action="update")
respond_interaction(interaction, content=f"You picked {chosen}")Members, roles and channels
Discord fills these in for you — you do not supply options.
@user_menu(placeholder="Pick a member", row=1)
async def pick_member(self, interaction, menu):
member_id = interaction.values[0]
@role_menu(placeholder="Pick a role", row=2)
async def pick_role(self, interaction, menu):
role_id = interaction.values[0]
@channel_menu(channel_types=["text", "voice"], placeholder="Pick a channel", row=3)
async def pick_channel(self, interaction, menu):
channel_id = interaction.values[0]Valid channel types: text, voice, category, news, news-thread,
public-thread, private-thread, forum, stage.
Menu options
| Option | Default | What it does |
|---|---|---|
placeholder | none | greyed-out text before a choice is made |
min_values | 0 | fewest choices allowed |
max_values | 1 | most choices allowed; raise it for multi-select |
is_disabled | False | greyed out |
row | none | which row it sits on |
custom_id | random | needed only to update the menu later |
Set max_values above 1 and interaction.values holds every choice.
Rows and layout
Discord allows 5 rows, and each row holds either up to 5 buttons or exactly one select menu — never a mix.
class Panel(View):
def __init__(self):
pass
@user_menu(placeholder="Who?", row=1)
async def who(self, interaction, menu): ...
@role_menu(placeholder="Which role?", row=2)
async def which(self, interaction, menu): ...
@button(label="Submit", style="success", row=3)
async def submit(self, interaction, button): ...Rows are drawn in number order, whatever order you declare them in. Break a layout rule and you get a plain message telling you which row is wrong, rather than a Discord error code.
Who is allowed to click
By default anyone in the server can click. That is fine for a public poll and wrong for a private confirmation.
Lock it to one person
view = AgreeView()
view.user_id = interaction.user.idLock it to two people
view = TradeView()
view.user_id = [user.id, target.id]Anyone else is ignored. Their click does nothing, and it does not reset the timeout, so a stranger cannot hold your view open.
Because the click is dropped before your code runs, the person gets Discord's red "interaction failed" with no explanation.
Or check it yourself, and say why
Leave user_id unset and you receive every click, so you can answer
politely:
class MarriageView(View):
def __init__(self):
self.answer = None
async def verify(self, interaction):
if interaction.user.id == target.id:
return True
await defer(interaction, action="create", ephemeral=True)
respond_interaction(interaction, content="This proposal isn't for you!")
return False
@button(label="Yes", style="success")
async def yes(self, interaction, button):
if not await self.verify(interaction):
return
self.answer = "yes"
await defer(interaction, action="update")
respond_interaction(interaction, empty=True)
self.stop()Use user_id when you want outsiders ignored. Write your own check when
you want to tell them why, or when the rule changes per button — whose
turn it is in a game, for example.
Call your check at the top of every button. Miss one and that button is open to everyone.
Changing buttons while the view is running
Give a button a custom_id and you can edit it later.
class Counter(View):
def __init__(self):
self.count = 0
@button(label="Clicked 0 times", custom_id="tally", style="primary")
async def tally(self, interaction, button):
self.count += 1
self.update_component("tally", label=f"Clicked {self.count} times")
await defer(interaction, action="update")
await self.update_view()| Method | What it does |
|---|---|
update_component(custom_id, **options) | change one component's label, style, emoji, is_disabled or hidden |
update_view() | redraw the message with your changes |
disable_all_components(ignore=[...]) | grey everything out, except the ids you list |
update_component needs the custom_id you gave the button. Without one it
has nothing to find.
Greying out at the end
exit_code = await view.wait(scope=scope, timeout=300)
if exit_code == "timeout":
view.disable_all_components()
await view.update_view()Worth doing. A timed-out view leaves live-looking buttons that answer every later click with a red error.
Timeout
exit_code = await view.wait(scope=scope, timeout=60)timeout is in seconds and defaults to 180 (three minutes).
It measures idle time, not total life — every accepted click starts the clock again, so a view someone is actively using stays open. Clicks you reject do not count.
wait() returns one of two values:
| Value | Meaning |
|---|---|
"stopped" | a callback called self.stop() |
"timeout" | nobody clicked in time |
Two optional settings on wait()
await view.wait(scope=scope, disable_on_timeout=True, ignore_errors=True)disable_on_timeout greys the buttons out for you when the view goes
idle, instead of you doing it by hand. Off by default.
ignore_errors keeps the view alive when one of your callbacks raises,
instead of ending it. Off by default, so a broken click ends the view and
tells you about it.
Reading the result
Anything you put on self is readable after wait() returns:
view = AgreeView()
respond_interaction(interaction, content="Do you agree?", components=view)
await view.wait(scope=scope)
if view.agree:
await add_roles(user.id, "Member")
response = "Welcome aboard!"
else:
response = "Maybe next time."This is the whole point of a view over loose buttons: the click handler and the code that reads the answer share one object.
A complete example: a two-person trade
class TradeView(View):
def __init__(self):
self.accepted = {}
self.players = [user.id, target.id]
async def verify(self, interaction):
if interaction.user.id in self.players:
return True
await defer(interaction, action="create", ephemeral=True)
respond_interaction(interaction, content="This trade isn't yours.")
return False
@button(label="Accept", style="success", custom_id="accept")
async def accept(self, interaction, button):
if not await self.verify(interaction):
return
self.accepted[interaction.user.id] = True
await defer(interaction, action="update")
waiting = len(self.players) - len(self.accepted)
if waiting:
self.update_component("accept", label=f"Accept ({waiting} to go)")
await self.update_view()
return
self.stop()
@button(label="Cancel", style="danger")
async def cancel(self, interaction, button):
if not await self.verify(interaction):
return
await defer(interaction, action="update")
respond_interaction(interaction, components=None, content="Trade cancelled.")
self.stop()
view = TradeView()
respond_interaction(
interaction,
content=f"{user.mention} wants to trade with {target.mention}",
components=view,
)
exit_code = await view.wait(scope=scope, timeout=120)
if exit_code == "timeout":
set_error("Nobody answered in time.")
response = "Trade complete!" if len(view.accepted) == 2 else "Trade cancelled."Both players may click, the button label counts down as they do, and the view ends the moment both have accepted.
Frequently asked questions
How do I add buttons to a Discord message with a custom command?
Write a class that extends View, decorate a method with @button, then
send the view with respond_interaction(interaction, components=view).
Why do my buttons show "This interaction failed"?
Either your callback did not answer the click within 3 seconds — every
callback needs a defer and usually a respond_interaction — or the view
already timed out and its buttons were left live. Grey them out when the
view ends.
How do I stop other members clicking my buttons?
Set view.user_id to the member who should own it, or to a list of members.
For a friendlier message, leave it unset and check
interaction.user.id yourself at the top of each callback.
How long does a view stay open?
Three minutes by default. Pass timeout= in seconds to change it. The timer
resets on every accepted click, so it measures idle time rather than total
life.
How do I change a button's label after someone clicks it?
Give the button a custom_id, then call update_component("that_id", label="New text") followed by await self.update_view().
Can two views run at the same time?
Yes. Every view gets its own components and its own ids, so two views — in one server or across different servers — never answer each other's clicks.
Loading comments…
Need help?
Have a question, a suggestion, or stuck on something? Reach out — we're happy to help.