NewSchokr 13 is here. A whole new UI, seven locations, snapshots and more. Read more →

How to add buttons and select menus to a Discord bot

Send buttons and select menus with discord.js v14 (ActionRowBuilder) or discord.py views, handle clicks, and keep them working after a restart.

Updated · 1 min read

Buttons and select menus are message components. You send them inside action rows, and when someone clicks, your bot receives an interaction that it must answer within 3 seconds. Each message can hold up to 5 action rows, and each row holds up to 5 buttons or a single select menu.

discord.js (v14)

Sending a button and a select menu

const {
  ActionRowBuilder,
  ButtonBuilder,
  ButtonStyle,
  StringSelectMenuBuilder,
} = require('discord.js');

const buttons = new ActionRowBuilder().addComponents(
  new ButtonBuilder().setCustomId('confirm').setLabel('Confirm').setStyle(ButtonStyle.Success),
  new ButtonBuilder().setCustomId('cancel').setLabel('Cancel').setStyle(ButtonStyle.Secondary),
  new ButtonBuilder().setLabel('Docs').setURL('https://discord.js.org').setStyle(ButtonStyle.Link),
);

const menu = new ActionRowBuilder().addComponents(
  new StringSelectMenuBuilder()
    .setCustomId('color')
    .setPlaceholder('Pick a colour')
    .addOptions(
      { label: 'Red', value: 'red' },
      { label: 'Green', value: 'green' },
      { label: 'Blue', value: 'blue' },
    ),
);

await interaction.reply({ content: 'Choose:', components: [buttons, menu] });

Link buttons use setURL and no custom ID; they open the link and don't send an interaction.

Handling clicks

client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.isButton()) {
    if (interaction.customId === 'confirm') {
      await interaction.update({ content: 'Confirmed.', components: [] });
    } else if (interaction.customId === 'cancel') {
      await interaction.update({ content: 'Cancelled.', components: [] });
    }
  } else if (interaction.isStringSelectMenu() && interaction.customId === 'color') {
    await interaction.reply(`You picked ${interaction.values[0]}.`);
  }
});

interaction.update() edits the message that holds the component. interaction.reply() sends a new message.

Because this handler matches on customId in the global interactionCreate event, buttons keep working after your bot restarts. Collectors (createMessageComponentCollector) are handy for short-lived flows, but they're lost on restart.

discord.py (views)

import discord


class Confirm(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=60)

    @discord.ui.button(label="Confirm", style=discord.ButtonStyle.success)
    async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.edit_message(content="Confirmed.", view=None)

    @discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
    async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.edit_message(content="Cancelled.", view=None)

    @discord.ui.select(
        placeholder="Pick a colour",
        options=[
            discord.SelectOption(label="Red", value="red"),
            discord.SelectOption(label="Green", value="green"),
        ],
    )
    async def pick(self, interaction: discord.Interaction, select: discord.ui.Select):
        await interaction.response.send_message(f"You picked {select.values[0]}.")


await interaction.response.send_message("Choose:", view=Confirm())

Views time out after 180 seconds by default (60 here). After that, or after a restart, clicking shows "This interaction failed".

Buttons that survive restarts

Make the view persistent: no timeout, a fixed custom_id on every component, and register it at startup.

class RolePanel(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    @discord.ui.button(label="Get role", style=discord.ButtonStyle.primary, custom_id="rolepanel:get")
    async def get_role(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.send_message("Done!", ephemeral=True)


class MyBot(commands.Bot):
    async def setup_hook(self):
        self.add_view(RolePanel())

Limits to know

ItemLimit
Action rows per message5
Buttons per row5
Select menus per row1 (takes the whole row)
Options per select menu25
Custom ID length100 characters

"This interaction failed" when clicking?

Your bot didn't answer within 3 seconds, the handler threw an error, or the view expired. See fixing "This interaction failed".