Buttons, selects, and modals

Buttons, select menus, and modals send an interaction back to your bot when someone uses them. Register a handler for each custom ID on your command application, next to your commands.

Send a button

Every clickable component needs a customId. Discord sends it back when the component is used.

import { commands } from "./commands";

commands.slash(
  { name: "ticket", description: "Open a support ticket" },
  async (interaction) => {
    await interaction.reply({
      content: "Need help? Open a ticket.",
      components: [
        discord.components.actionRow(
          discord.components.button({
            customId: "ticket:open",
            label: "Open ticket",
            style: discord.ButtonStyle.PRIMARY,
          }),
        ),
      ],
    });
  },
);

Handle the click

commands.button() runs when a button with that custom ID is clicked.

commands.button("ticket:open", async (interaction) => {
  await interaction.reply({ content: "Ticket opened.", ephemeral: true });
});

Reply, defer, or update the message within three seconds. If you don’t, Discord shows the user “This interaction failed”. Use interaction.update() to edit the message the button is on, or interaction.deferUpdate() when you only need to acknowledge the click.

Put data in the custom ID

A handler also runs for custom IDs that start with its ID followed by :. The rest of the custom ID is split on : and passed as params.

// A button created with customId: `poll:vote:${pollId}:yes`
commands.button("poll:vote", async (interaction, [pollId, choice]) => {
  await recordVote(pollId, interaction.user!.id, choice);
  await interaction.reply({ content: `Voted ${choice}.`, ephemeral: true });
});

When more than one handler could match, the longest registered ID wins. With handlers for "ticket" and "ticket:close", the custom ID "ticket:close:55" goes to "ticket:close" with params set to ["55"].

A custom ID can be at most 100 characters, so store larger state in KV storage and put its key in the ID.

Select menus

commands.select() handles string, user, role, mentionable, and channel selects. The chosen values are in interaction.values.

commands.select("roles:pick", async (interaction) => {
  const member = interaction.member;
  if (!member) return;

  for (const roleId of interaction.values) {
    await member.addRole(roleId, "Picked from the role menu");
  }
  await interaction.reply({ content: "Roles updated.", ephemeral: true });
});

Modals

Open a modal from a command or component handler with interaction.showModal(), then handle its submission with commands.modal().

commands.button("feedback:open", async (interaction) => {
  await interaction.showModal({
    customId: "feedback:submit",
    title: "Send feedback",
    components: [
      discord.components.actionRow(
        discord.components.textInput({
          customId: "message",
          label: "What should we change?",
          style: discord.TextInputStyle.PARAGRAPH,
        }),
      ),
    ],
  });
});

commands.modal("feedback:submit", async (interaction) => {
  await saveFeedback(interaction.user!.id, interaction.getTextInput("message"));
  await interaction.reply({ content: "Thanks for the feedback.", ephemeral: true });
});

A modal must be the first response to an interaction, so call showModal() before replying or deferring.

Filters, permissions, and cooldowns

Pass a configuration object instead of a string to use the same filters, permissions, and cooldowns as commands. The application’s filters and permissions apply here too.

commands.button(
  {
    customId: "report:delete",
    permissions: { user: [discord.PermissionFlags.MANAGE_MESSAGES] },
    cooldown: 5_000,
  },
  async (interaction, [channelId, messageId]) => {
    const channel = await discord.fetchGuildTextChannel(channelId);
    await (await channel?.fetchMessage(messageId))?.delete();
    await interaction.update({ content: "Message deleted.", components: [] });
  },
);

Failures are sent as ephemeral replies, so a message with shared buttons doesn’t fill up with “You need the Manage Messages permission” messages. In a filter or error handler, component handlers have context.kind set to "component" and context.commandName set to the registered custom ID.

Components without a handler

An interaction whose custom ID matches no handler is ignored by the command application. It still reaches your discord.on(discord.events.INTERACTION_CREATE, ...) handlers, so existing code that checks interaction.customId keeps working.

Components on messages sent by an earlier revision keep working as long as the current revision registers a handler for their custom ID.