Select menus for choices

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.

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:

  • 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.
  • 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.values is always an array. With maxValues above 1 the user can pick several — loop over interaction.values instead 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 in InteractionCreate and reply or update() promptly.
  • The menu shows but picking does nothing — the 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.

Next steps

Was this guide helpful?