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
| Item | Limit |
|---|---|
| Action rows per message | 5 |
| Buttons per row | 5 |
| Select menus per row | 1 (takes the whole row) |
| Options per select menu | 25 |
| Custom ID length | 100 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".