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

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

·10 min read

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

py
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 a NameError.


Handling a click

Every callback receives three things:

py
async def yes(self, interaction, button):
NameWhat it is
selfthe view, with all your state on it
interactionthe click — who clicked, and how you answer them
buttonthe 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:

py
await defer(interaction, action="update")
respond_interaction(interaction, content="Saved!")

Send a private reply only that person sees:

py
await defer(interaction, action="create", ephemeral=True)
respond_interaction(interaction, content="That is not for you.")

Acknowledge and change nothing:

py
await defer(interaction, action="update")
respond_interaction(interaction, empty=True)

Remove the buttons:

py
respond_interaction(interaction, components=None, content="Cancelled")

Button options

py
@button(
    label="Confirm",
    custom_id="confirm",
    style="success",
    emoji="✅",
    row=1,
    is_disabled=False,
    hidden=False,
)
async def confirm(self, interaction, button):
    ...
OptionDefaultWhat it does
labelrequiredthe text on the button
style"primary"primary, secondary, success, danger
custom_idrandomneeded only if you want to update this button later
emojinoneunicode, or a custom one as <:name:id>
row1which row it sits on, 1 to 5
is_disabledFalsegreyed out and unclickable
hiddenFalsenot sent at all — useful for a button you reveal later
urlnonemakes it a link button

Styles

  • primary — blurple, for the main action
  • secondary — grey, for everything else
  • success — green
  • danger — red, for anything destructive

Link buttons

py
@button(label="Open the docs", url="https://ccbot.app")
async def docs(self, interaction, button):
    pass  # never called; Discord handles link buttons itself

Passing your own data

Extra keyword arguments are stored on the button and handed back to you:

py
@button(label="‎", row=1, index=0)
async def one(self, interaction, button):
    self.board[button.metadata["index"]] = interaction.user.id

That 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

py
@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.

py
@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

OptionDefaultWhat it does
placeholdernonegreyed-out text before a choice is made
min_values0fewest choices allowed
max_values1most choices allowed; raise it for multi-select
is_disabledFalsegreyed out
rownonewhich row it sits on
custom_idrandomneeded 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.

py
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

py
view = AgreeView()
view.user_id = interaction.user.id

Lock it to two people

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

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

py
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()
MethodWhat 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

py
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

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

ValueMeaning
"stopped"a callback called self.stop()
"timeout"nobody clicked in time

Two optional settings on wait()

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

py
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

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

Join support server
Custom CommandsCustom Commands

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

Join the support server

Product

  • Overview
  • Why us?
  • Premium
  • Template shop

Resources

  • Blog
  • Support
  • Dashboard

Company

  • Privacy
  • Contact
[email protected]

2 Frederick Street, London, WC1X 0ND

Payments secured by Stripe
{ / } custom commands

© WEiRDSOFT LTD. All rights reserved.