Only show guides for your app
When a command needs one pick from a list of ten, buttons get silly. Select menus are the dropdown answer — string, user, role, channel and mentionable — with the discord.js v14 code to build and read them.
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.
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.
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:
setCustomId is the string Discord hands back when someone picks — how you know which menu was used, the same idea as a button's custom ID.setPlaceholder is the greyed-out prompt shown before a pick.setMinValues / setMaxValues control how many options the user may choose. 1/1 is a single pick; raise maxValues to allow several.Send it from a slash command:
if (interaction.isChatInputCommand() && interaction.commandName === 'color') {
await interaction.reply({ content: 'Pick a colour:', components: [colourRow] });
}
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 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 |
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.
isStringSelectMenu() branch. Handle the menu in InteractionCreate and reply or update() promptly.customId in your guard doesn't match the one you set on the builder. They must be identical strings.interaction.values is undefined — you're reading it on a button or slash interaction. Only select-menu interactions carry values; guard first.Invalid Form Body on send — an empty options list, a label over 100 characters, or minValues above maxValues. Every string option needs a label and a value.