Message Components
Add buttons and select menus to messages
Message components add interactive elements like buttons and select menus to your bot's messages. Build them with Discord.js's component builders and handle interactions through events.
Creating a button and handling its click are two separate steps in two separate files. The command file sends the button; an event handler file responds to clicks. This two-file pattern applies to all interactive components.
For the complete reference including all 5 select menu types, collectors, update() vs reply(), and ephemeral components, see the @robojs/discordjs Components reference.
Action Rows
ActionRowBuilder is the container for components. Each message supports up to 5 action rows, and each row holds one type of component. The <ButtonBuilder> generic tells TypeScript this row contains buttons.
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('confirm')
.setLabel('Confirm')
.setStyle(ButtonStyle.Primary)
)
export default () => {
return { content: 'Click a button:', components: [row] }
}import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('confirm')
.setLabel('Confirm')
.setStyle(ButtonStyle.Primary)
)
export default () => {
return { content: 'Click a button:', components: [row] }
}Buttons
Buttons are the simplest interactive component. Each button needs a customId for identification (except link buttons, which use a URL instead).
| Style | Constant | Color |
|---|---|---|
| Primary | ButtonStyle.Primary | Blurple |
| Secondary | ButtonStyle.Secondary | Grey |
| Success | ButtonStyle.Success | Green |
| Danger | ButtonStyle.Danger | Red |
| Link | ButtonStyle.Link | Grey (opens URL) |
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'
export default () => {
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('yes').setLabel('Yes').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId('no').setLabel('No').setStyle(ButtonStyle.Danger)
)
return { content: 'Do you agree?', components: [row] }
}import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'
export default () => {
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('yes').setLabel('Yes').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId('no').setLabel('No').setStyle(ButtonStyle.Danger)
)
return { content: 'Do you agree?', components: [row] }
}Discord message from a bot saying 'Do you agree?' with two buttons: a green 'Yes' (Success style) and a red 'No' (Danger style) in an action row
Handling Button Clicks
Create an interactionCreate event handler to respond when a user clicks a button. Place this file at src/events/interactionCreate/buttons.ts to use the stacked handler pattern.
import type { Interaction } from 'discord.js'
export default async (interaction: Interaction) => {
if (!interaction.isButton()) return
if (interaction.customId === 'yes') {
await interaction.reply({ content: 'You agreed!', ephemeral: true })
} else if (interaction.customId === 'no') {
await interaction.reply({ content: 'You disagreed!', ephemeral: true })
}
}export default async (interaction) => {
if (!interaction.isButton()) return
if (interaction.customId === 'yes') {
await interaction.reply({ content: 'You agreed!', ephemeral: true })
} else if (interaction.customId === 'no') {
await interaction.reply({ content: 'You disagreed!', ephemeral: true })
}
}Select Menus
Select menus let users pick from a dropdown list. StringSelectMenuBuilder creates a menu with predefined string options.
import { ActionRowBuilder, StringSelectMenuBuilder } from 'discord.js'
export default () => {
const menu = new StringSelectMenuBuilder()
.setCustomId('color-select')
.setPlaceholder('Choose a color')
.addOptions(
{ label: 'Red', value: 'red' },
{ label: 'Green', value: 'green' },
{ label: 'Blue', value: 'blue' }
)
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)
return { content: 'Pick a color:', components: [row] }
}import { ActionRowBuilder, StringSelectMenuBuilder } from 'discord.js'
export default () => {
const menu = new StringSelectMenuBuilder()
.setCustomId('color-select')
.setPlaceholder('Choose a color')
.addOptions(
{ label: 'Red', value: 'red' },
{ label: 'Green', value: 'green' },
{ label: 'Blue', value: 'blue' }
)
const row = new ActionRowBuilder().addComponents(menu)
return { content: 'Pick a color:', components: [row] }
}Discord message with a select menu below showing placeholder 'Choose a color' and the dropdown expanded to reveal Red, Green, and Blue options
Discord.js also provides UserSelectMenuBuilder, RoleSelectMenuBuilder, and ChannelSelectMenuBuilder for selecting Discord entities directly.
Handling Selections
import type { Interaction } from 'discord.js'
export default async (interaction: Interaction) => {
if (!interaction.isStringSelectMenu()) return
if (interaction.customId !== 'color-select') return
const selected = interaction.values[0]
await interaction.reply({ content: `You selected: ${selected}`, ephemeral: true })
}export default async (interaction) => {
if (!interaction.isStringSelectMenu()) return
if (interaction.customId !== 'color-select') return
const selected = interaction.values[0]
await interaction.reply({ content: `You selected: ${selected}`, ephemeral: true })
}Disabling Components
Call setDisabled(true) on a component builder to grey it out. This is useful after a user has interacted, preventing further clicks.
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'
import type { Interaction } from 'discord.js'
export default async (interaction: Interaction) => {
if (!interaction.isButton()) return
const disabledRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('done').setLabel('Done').setStyle(ButtonStyle.Secondary).setDisabled(true)
)
await interaction.update({ components: [disabledRow] })
}import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'
export default async (interaction) => {
if (!interaction.isButton()) return
const disabledRow = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('done').setLabel('Done').setStyle(ButtonStyle.Secondary).setDisabled(true)
)
await interaction.update({ components: [disabledRow] })
}Discord message showing a greyed-out disabled button that cannot be clicked, demonstrating setDisabled(true)
As an alternative to the two-file pattern, Discord.js also supports collectors that listen for component interactions within the same file. See the Discord.js collector guide for details.
