Buttons, select menus and modals in discord.js

In discord.js 14 you build buttons, menus and forms with builders, send them with a message or open them, and handle what people do with them in interactionCreate or with a collector. Here is each one, with the code.

10 min readIntermediateUpdated Examples tested with discord.js 14.27

This uses discord.js 14.23 or newer (labels in modals arrived in 14.23). Each example answers a slash command; the slash commands guide covers setting those up. The require lines are shown once and the later examples assume them.

A message with a button

Buttons and menus sit in action rows, up to five per row and five rows per message:

JavaScript
const {
  ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, Events, LabelBuilder,
  MessageFlags, ModalBuilder, RoleSelectMenuBuilder, StringSelectMenuBuilder,
  StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle,
} = require("discord.js");

const row = 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://example.com").setStyle(ButtonStyle.Link),
);

await interaction.reply({ content: "Are you sure?", components: [row] });

The styles are Primary (blue), Secondary (grey), Success (green), Danger (red) and Link. A link button opens a web page and never reaches your bot, so it has a URL instead of a custom ID. Every other button needs a customId, up to 100 characters, which is how you tell presses apart.

Handling the press

A press arrives as an interaction, like a slash command does. Answer it within three seconds or the user sees "This interaction failed":

JavaScript
client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isButton()) return;

  if (interaction.customId === "confirm") {
    await interaction.update({ content: "Done.", components: [] });
  } else if (interaction.customId === "cancel") {
    await interaction.reply({ content: "Cancelled.", flags: MessageFlags.Ephemeral });
  }
});

update answers the press by editing the message it came from; here it also removes the buttons. reply sends a new message, and MessageFlags.Ephemeral shows it only to the person who pressed. For slow work, await interaction.deferUpdate() or deferReply() first, then editReply or followUp.

Handling presses in interactionCreate keeps working after the bot restarts, because nothing is held in memory: the custom ID on the message is all it needs. That is the right place for buttons that live in a channel for months, such as role pickers and ticket panels.

Waiting for one person, with a time limit

For a question asked in a command, such as a confirmation, a collector is tidier: it listens on that one message, for as long as you say, and can ignore everyone but the person who asked:

JavaScript
const response = await interaction.reply({ content: "Are you sure?", components: [row], withResponse: true });
const message = response.resource.message;

try {
  const press = await message.awaitMessageComponent({
    componentType: ComponentType.Button,
    filter: (i) => i.user.id === interaction.user.id,
    time: 60_000,
  });
  await press.update({ content: press.customId === "confirm" ? "Done." : "Cancelled.", components: [] });
} catch {
  // nobody pressed in time
  await interaction.editReply({ content: "No answer, nothing changed.", components: [] });
}

withResponse: true returns the sent message, which the collector listens on. awaitMessageComponent waits for one press; message.createMessageComponentCollector({ time }) keeps collecting until the time runs out and fires a collect event for each. A collector lives in memory, so a restart ends it.

The filter only decides what the collector handles. Someone else's press still needs an answer, or they see "This interaction failed": either answer it in interactionCreate, or filter there and tell them the menu is not theirs.

Select menus

JavaScript
const menu = new StringSelectMenuBuilder()
  .setCustomId("colour")
  .setPlaceholder("Pick a colour")
  .addOptions(
    new StringSelectMenuOptionBuilder().setLabel("Red").setValue("red").setEmoji("🟥"),
    new StringSelectMenuOptionBuilder().setLabel("Blue").setValue("blue").setEmoji("🟦"),
  );

await interaction.reply({ components: [new ActionRowBuilder().addComponents(menu)] });

// in interactionCreate
if (interaction.isStringSelectMenu() && interaction.customId === "colour") {
  await interaction.reply({ content: `You picked ${interaction.values[0]}`, flags: MessageFlags.Ephemeral });
}

A menu holds up to 25 options and takes a whole row. setMinValues and setMaxValues let people pick several; interaction.values is always a list. To pick from the server itself, use UserSelectMenuBuilder, RoleSelectMenuBuilder, ChannelSelectMenuBuilder or MentionableSelectMenuBuilder; Discord fills in the choices and interaction.roles (or .users, .channels) holds what was picked:

JavaScript
const roles = new RoleSelectMenuBuilder().setCustomId("roles").setPlaceholder("Pick up to three roles").setMaxValues(3);

// in interactionCreate
if (interaction.isRoleSelectMenu() && interaction.customId === "roles") {
  const names = interaction.roles.map((role) => role.name).join(", ");
  await interaction.reply({ content: `You picked ${names}`, flags: MessageFlags.Ephemeral });
}

Modals: a pop-up form

A modal is a form that opens over Discord. Each field is a LabelBuilder with a text input (or a select menu) inside it:

JavaScript
const modal = new ModalBuilder()
  .setCustomId("feedback")
  .setTitle("Send feedback")
  .addLabelComponents(
    new LabelBuilder()
      .setLabel("Your name")
      .setTextInputComponent(new TextInputBuilder().setCustomId("name").setStyle(TextInputStyle.Short).setMaxLength(50)),
    new LabelBuilder()
      .setLabel("What could be better?")
      .setDescription("Up to 1000 characters")
      .setTextInputComponent(new TextInputBuilder().setCustomId("text").setStyle(TextInputStyle.Paragraph).setMaxLength(1000)),
  );

await interaction.showModal(modal);

// in interactionCreate
if (interaction.isModalSubmit() && interaction.customId === "feedback") {
  const name = interaction.fields.getTextInputValue("name");
  await interaction.reply({ content: `Thanks, ${name}.`, flags: MessageFlags.Ephemeral });
}

showModal has to be the first answer to an interaction: a slash command or a button press can open one, but not after a reply or a defer, and a bot cannot open one on its own. A modal holds up to five fields.

Older code puts each TextInputBuilder, with its own setLabel, in an action row and passes those to addComponents. That still runs in discord.js 14, but both are deprecated in favour of labels.

Common errors

  • "This interaction failed": nothing answered within three seconds, the handler threw (look in your console), or a collector had already stopped. Defer first when the work is slow.
  • InteractionAlreadyReplied: the code answered twice. After the first answer, use followUp or editReply.
  • "Invalid Form Body", custom_id: two components in one message share a custom ID, or a button that is not a link has none.
  • A button and a menu in the same row: a select menu needs a row of its own.
  • The buttons stopped working after a restart: they were handled by a collector. Handle long-lived buttons in interactionCreate by their custom ID.

Press / or Ctrl K on any page to search.