Buttons are great for two or three actions. Ask someone to pick one colour out of ten and you'd need ten buttons — a mess. A select menu is the dropdown built for exactly this: the user opens it, picks, and your bot reads the choice back. This recipe builds a working colour picker, then shows the other four menu types and when to reach for each.
| At a glance | |
|---|---|
| You need | a working bot from the discord.js guide, and comfort with Slash commands in depth |
| Plan | free or premium — free runs while your session timer lasts |
| Time | about fifteen minutes |
This picks up right where the slash-commands guide left off — same Client, same InteractionCreate handler. We only add the menu.
The five kinds of menu
Discord gives you one dropdown component with five flavours. Pick by what the user is choosing:
| Menu type | User picks from | Reach for it when |
|---|---|---|
| String | options you write yourself | fixed choices — colours, difficulties, categories |
| User | members of the server | "who do you want to report/gift/promote?" |
| Role | roles in the server | "which role should this apply to?" |
| Channel | channels in the server | "where should logs go?" |
| Mentionable | users and roles together | either a person or a group |
The four built-in ones (user, role, channel, mentionable) fill themselves from the server — you don't list the options. Only the string menu takes options you define.
Build a string select menu
Import the builders alongside your usual discord.js imports:
const {
Client, Events, GatewayIntentBits, SlashCommandBuilder,
ActionRowBuilder,
StringSelectMenuBuilder, StringSelectMenuOptionBuilder,
} = require('discord.js');
A menu lives inside an action row, exactly like a button. Build the row once:
const colourRow = new ActionRowBuilder().addComponents(
new StringSelectMenuBuilder()
.setCustomId('pick-color')
.setPlaceholder('Choose a colour')
.setMinValues(1)
.setMaxValues(1)
.addOptions(
new StringSelectMenuOptionBuilder().setLabel('Red').setValue('red').setEmoji('🔴'),
new StringSelectMenuOptionBuilder().setLabel('Green').setValue('green').setEmoji('🟢').setDescription('The calm one'),
new StringSelectMenuOptionBuilder().setLabel('Blue').setValue('blue').setEmoji('🔵'),
),
);
The pieces:
setCustomIdis the string Discord hands back when someone picks — how you know which menu was used, the same idea as a button's custom ID.setPlaceholderis the greyed-out prompt shown before a pick.setMinValues/setMaxValuescontrol how many options the user may choose.1/1is a single pick; raisemaxValuesto allow several.- Each option has a label (what the user sees), a value (what your code receives), and optional emoji and description.
Send it from a slash command:
if (interaction.isChatInputCommand() && interaction.commandName === 'color') {
await interaction.reply({ content: 'Pick a colour:', components: [colourRow] });
}
Read the choice back
A pick arrives through the same InteractionCreate event as everything else. Guard for it with isStringSelectMenu(), then read interaction.values — always an array, because a menu can allow more than one pick:
if (interaction.isStringSelectMenu() && interaction.customId === 'pick-color') {
const choice = interaction.values[0]; // 'red' | 'green' | 'blue'
await interaction.update({ content: `You chose **${choice}**.`, components: [] });
}
interaction.update() edits the original message in place — here it swaps the prompt for the result and passes components: [] to remove the menu so it can't be used twice. (Prefer a fresh, private message instead? Use interaction.reply({ ..., ephemeral: true }).)
💡 Tip:
interaction.valuesis always an array. WithmaxValuesabove 1 the user can pick several — loop overinteraction.valuesinstead of grabbing[0].
The other four menus
The built-in menus are even simpler because you don't supply options — Discord populates them. Swap the builder and the guard:
const { RoleSelectMenuBuilder } = require('discord.js');
const roleRow = new ActionRowBuilder().addComponents(
new RoleSelectMenuBuilder().setCustomId('pick-role').setPlaceholder('Choose a role').setMaxValues(3),
);
if (interaction.isRoleSelectMenu() && interaction.customId === 'pick-role') {
const roleIds = interaction.values; // array of role IDs
await interaction.reply({ content: `You picked ${roleIds.length} role(s).`, ephemeral: true });
}
Each type pairs one builder with one guard, and interaction.values gives you IDs:
| Builder | Guard | values contains |
|---|---|---|
StringSelectMenuBuilder |
isStringSelectMenu() |
your option values |
UserSelectMenuBuilder |
isUserSelectMenu() |
user IDs |
RoleSelectMenuBuilder |
isRoleSelectMenu() |
role IDs |
ChannelSelectMenuBuilder |
isChannelSelectMenu() |
channel IDs |
MentionableSelectMenuBuilder |
isMentionableSelectMenu() |
user and role IDs |
Limits worth knowing
- A string menu holds up to 25 options. More than that and you want autocomplete instead — it filters as the user types.
- A menu takes a whole action row to itself — it can't share a row with buttons. A message can hold up to five rows.
- Menu vs buttons: a handful of actions → buttons; one pick from a list → a select menu; a long or open-ended list → autocomplete.
Verify it works
Register a /color command, start the bot, and run it. The dropdown appears; pick an option and the message updates to your choice. If the reply comes back and the menu disappears, the round trip works.
Troubleshooting
- "This interaction failed" after picking — your handler didn't answer within about three seconds, or there's no
isStringSelectMenu()branch. Handle the menu inInteractionCreateand reply orupdate()promptly. - The menu shows but picking does nothing — the
customIdin your guard doesn't match the one you set on the builder. They must be identical strings. interaction.valuesis undefined — you're reading it on a button or slash interaction. Only select-menu interactions carryvalues; guard first.Invalid Form Bodyon send — an empty options list, a label over 100 characters, orminValuesabovemaxValues. Every string option needs a label and a value.