Get your free server today! View Plans →
Home Plans Blog About Contact Panel Join Discord
Discord Bots

Adding buttons, menus and modals to your Discord bot

A hands-on guide to adding interactive buttons, drop-down select menus and pop-up modals to a Discord bot, shown in both discord.py and discord.js.

Adding buttons, menus and modals to your Discord bot

By the end of this guide you'll have a Discord bot that shows clickable buttons, drop-down select menus and pop-up forms (modals), and you'll know how to handle what people click or type. We'll write it twice, once in Python with discord.py and once in JavaScript with discord.js, so you can follow along in whichever you already use. This is for anyone who has a basic bot running and wants to swap text-only commands for something people can actually point and click.

What message components are

A plain bot reply is just text. Message components are the interactive bits Discord lets you attach to that reply: buttons, select menus, and a button or command can open a modal, which is a small form with text boxes. When a user clicks a button or submits a form, Discord sends your bot an interaction, and your code decides what happens next.

Components live inside rows. Each message can hold up to five rows. A row holds either up to five buttons, or a single select menu. You can't mix a button and a select menu in the same row. Keep that limit in mind and you'll avoid most layout headaches before they start.

One rule sits underneath all of this. When an interaction arrives, you have 3 seconds to respond, or Discord shows the user "This interaction failed". You don't have to finish the real work in 3 seconds, you just have to acknowledge it. We'll come back to that, because it's the single most common thing that trips people up.

Step 1: Add a button

In discord.py, buttons live on a View. A View is a container you attach to a message, and each button is a method decorated with @discord.ui.button. The method runs when someone clicks.

import discord
from discord.ext import commands

intents = discord.Intents.default()
bot = commands.Bot(command_prefix="!", intents=intents)

class Confirm(discord.ui.View):
    @discord.ui.button(label="Click me", style=discord.ButtonStyle.green)
    async def hello(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.send_message("You clicked it!", ephemeral=True)

@bot.command()
async def panel(ctx):
    await ctx.send("Here is a button:", view=Confirm())

bot.run("YOUR_TOKEN")

Run that, type !panel in a channel, and you'll see a green button. Click it and only you see the reply.

In discord.js you build the same thing with an action row and a ButtonBuilder, then attach the row to the message.

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

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.on('messageCreate', async (message) => {
  if (message.content === '!panel') {
    const button = new ButtonBuilder().setCustomId('hello').setLabel('Click me').setStyle(ButtonStyle.Success);
    const row = new ActionRowBuilder().addComponents(button);
    await message.channel.send({ content: 'Here is a button:', components: [row] });
  }
});

client.login('YOUR_TOKEN');

Notice the setCustomId('hello'). That id is how your bot knows which button got pressed, so give every button its own id.

Step 2: Handle the click

In discord.py the handler is already wired up. The method under the decorator is the click handler, so there's nothing extra to do. The interaction argument is your way to reply, and interaction.response.send_message answers it.

In discord.js, all component clicks come through one event, interactionCreate. You check the customId to figure out which button fired.

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isButton()) return;

  if (interaction.customId === 'hello') {
    await interaction.reply({ content: 'You clicked it!', ephemeral: true });
  }
});

That ephemeral: true means the reply is private. Only the person who clicked sees it, and it disappears on its own. Use it for confirmations, error messages and anything you don't want cluttering the channel for everyone else.

Step 3: Add a select menu

A select menu is a drop-down. It's the right tool when there are more than a handful of choices and a wall of buttons would look messy. Picture a role picker or a "choose your region" prompt.

In discord.py you subclass it or use the @discord.ui.select decorator. Each option has a label the user sees and a value your code reads.

class Picker(discord.ui.View):
    @discord.ui.select(
        placeholder="Pick a color",
        options=[
            discord.SelectOption(label="Red", value="red"),
            discord.SelectOption(label="Green", value="green"),
            discord.SelectOption(label="Blue", value="blue"),
        ],
    )
    async def choose(self, interaction: discord.Interaction, select: discord.ui.Select):
        await interaction.response.send_message(
            f"You picked {select.values[0]}", ephemeral=True
        )

The selected value lands in select.values, which is a list because a menu can allow more than one pick. For a single choice, read select.values[0].

The discord.js version uses a StringSelectMenuBuilder inside an action row.

const { StringSelectMenuBuilder } = require('discord.js');

const menu = new StringSelectMenuBuilder().setCustomId('color').setPlaceholder('Pick a color').addOptions(
    { label: 'Red', value: 'red' },
    { label: 'Green', value: 'green' },
    { label: 'Blue', value: 'blue' },
  );
const row = new ActionRowBuilder().addComponents(menu);
await message.channel.send({ content: 'Choose:', components: [row] });

Handle it like a button, but check for a select instead.

if (interaction.isStringSelectMenu() && interaction.customId === 'color') {
  await interaction.reply({ content: `You picked ${interaction.values[0]}`, ephemeral: true });
}

Step 4: Collect typed input with a modal

Buttons and menus are fine for fixed choices. When you need someone to type something (a reason, a ticket subject, a feedback note) you want a modal. A modal is a pop-up form, and the important rule is that you can only open one in direct response to an interaction. So a modal opens from a button click or a slash command, never out of nowhere.

Here's a discord.py modal with two text fields. We open it when a button is clicked.

class FeedbackModal(discord.ui.Modal, title="Send feedback"):
    subject = discord.ui.TextInput(label="Subject", max_length=100)
    body = discord.ui.TextInput(
        label="Details",
        style=discord.TextStyle.paragraph,
        required=False,
    )

    async def on_submit(self, interaction: discord.Interaction):
        await interaction.response.send_message(
            f"Thanks! Subject: {self.subject.value}", ephemeral=True
        )

class OpenForm(discord.ui.View):
    @discord.ui.button(label="Open form", style=discord.ButtonStyle.blurple)
    async def form(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.send_modal(FeedbackModal())

Note interaction.response.send_modal instead of send_message. Opening the modal counts as your response, so don't try to reply with text first. When the user submits, on_submit runs and the typed values are on self.subject.value and self.body.value.

In discord.js you build the modal and its fields, then show it when the button is clicked.

const { ModalBuilder, TextInputBuilder, TextInputStyle } = require('discord.js');

if (interaction.isButton() && interaction.customId === 'open_form') {
  const modal = new ModalBuilder().setCustomId('feedback').setTitle('Send feedback');

  const subject = new TextInputBuilder().setCustomId('subject').setLabel('Subject').setStyle(TextInputStyle.Short).setMaxLength(100);

  const body = new TextInputBuilder().setCustomId('body').setLabel('Details').setStyle(TextInputStyle.Paragraph).setRequired(false);

  modal.addComponents(
    new ActionRowBuilder().addComponents(subject),
    new ActionRowBuilder().addComponents(body),
  );
  await interaction.showModal(modal);
}

The submit comes back as its own interaction, so handle it separately.

if (interaction.isModalSubmit() && interaction.customId === 'feedback') {
  const subject = interaction.fields.getTextInputValue('subject');
  await interaction.reply({ content: `Thanks! Subject: ${subject}`, ephemeral: true });
}

Step 5: Make components survive a restart

Here's a problem you'll hit fast. Build a button, restart the bot, then click the old button. Nothing happens, and after a moment Discord says the interaction failed. That's because a normal View only lives in memory. When the bot stops, it forgets the button exists.

The fix is a persistent view. You give every component a fixed custom_id, set the View timeout to None, and register the View again when the bot starts. Now the bot recognizes clicks on buttons it posted days ago.

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

    @discord.ui.button(label="Get role", style=discord.ButtonStyle.green, custom_id="get_role")
    async def get_role(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.send_message("Role given.", ephemeral=True)

@bot.event
async def setup_hook():
    bot.add_view(RolePanel())  # re-register on every startup

The two things that matter: a fixed custom_id on each component, and that bot.add_view call in setup_hook so the bot knows about the View again after a restart.

discord.js handles this more simply. Because every click runs through your one interactionCreate handler and you match on customId, persistence is automatic as long as the custom id stays the same and you don't rely on collectors that expire. Just make sure the handler is registered at startup, which it always is.

Step 6: When and why to defer

Remember the 3 second rule. If your click handler does anything slow (a database write, an API call, fetching a big list) you'll blow past it and the user sees a failure even though your code is fine. The answer is to defer. Deferring tells Discord "I got it, give me more time", and it shows a loading state. You then send the real reply when you're ready, up to 15 minutes later.

In discord.py:

async def slow(self, interaction: discord.Interaction, button: discord.ui.Button):
    await interaction.response.defer(ephemeral=True)
    result = await do_slow_work()   # takes a few seconds
    await interaction.followup.send(f"Done: {result}", ephemeral=True)

And in discord.js:

await interaction.deferReply({ ephemeral: true });
const result = await doSlowWork();
await interaction.editReply(`Done: ${result}`);

The pattern is the same in both: defer first, do the work, then follow up. For anything instant you can skip the defer and reply straight away, but if you're ever unsure, deferring is the safe choice.

Troubleshooting

These are the ones that come up again and again when people first add components.

SymptomCauseFix
This interaction failedYou didn't respond within 3 secondsDefer first, then send a followup once the work is done
Buttons stop working after a restartThe View only lived in memory and was forgottenUse a persistent View: a fixed custom_id, timeout=None, and re-add it in setup_hook
Old buttons suddenly go deadThe View hit its timeout (default 180 seconds)Raise the timeout, or set it to None for components meant to last
InteractionResponded errorYou replied twice to one interactionRespond once. After that, use followup.send (Python) or editReply (Node)
Modal won't openYou tried to reply with text before showing itShowing the modal is the response, so call send_modal or showModal first with nothing before it
Component shows but clicks do nothingCustom ids don't match between sending and handlingMake the id you set match the id you check exactly, character for character

If a button worked yesterday and is dead today, the cause is almost always the timeout or a restart, not your logic. Check those two first.

Where to go from here

You can now post buttons, drop-downs and forms, read what people click or type, keep replies private with ephemeral, and make panels that survive a restart. A few good next moves: build a self-service role panel from persistent buttons, turn a modal into a support ticket that opens a private channel, or chain a select menu into a modal so the choice fills in the form. If you're running the bot on a small VPS or a managed host like ours at Bytte.cloud, set it to restart on boot so your persistent panels come right back after any reboot. Keep custom ids unique, respect the 3 second rule, and test new panels in a private server before you post them where everyone can poke at them.

Common questions

What are message components in Discord?

They are the interactive parts you attach to a bot's reply: buttons, select menus and modals (pop-up forms). When a user clicks or submits one, Discord sends your bot an interaction and your code decides what happens.

Why does my button say This interaction failed?

You have 3 seconds to respond to an interaction. If your handler is slow, call defer first to buy more time, then send a followup once the work is done.

Why do my buttons stop working after I restart the bot?

A normal View only lives in memory, so a restart forgets it. Use a persistent view: give each component a fixed custom_id, set the timeout to None, and re-register it on startup with bot.add_view in setup_hook.

How do I make a button reply private?

Send the reply with ephemeral set to true. Only the person who clicked sees it and it goes away on its own, which is ideal for confirmations and error messages.

How do I collect typed input from a user?

Use a modal, which is a pop-up form with text fields. A modal can only open in direct response to an interaction, so trigger it from a button click or a slash command, then read the values when the user submits.

PN
Priya Nair
Community Manager at Bytte.cloud

Part of the Bytte.cloud team. We run game servers, bots and websites for a living, and we write these guides from what we see day to day in support and on our own servers.

Want to try this on real hardware?

Bytte.cloud has free plans for game servers, bots and websites. No credit card, set up in seconds.

Start for free See the plans