Scripting Guide
Lesson 8: Select Menus and Modals
Dropdown menus for choices and modal forms for free-text input, plus how to read what the user picked or typed.
String select menus
A select menu offers up to 25 options in a dropdown. Like buttons, menus are posted by one agent and handled by an interactionCreate agent through their customId.
import {
ActionRowBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
} from "discord";
export async function onMessage(message) {
if (message.author.bot) return;
if (message.content !== "!colors") return;
const menu = new StringSelectMenuBuilder()
.setCustomId("colorpick:menu")
.setPlaceholder("Choose your favorite color")
.setMinValues(1)
.setMaxValues(2) // allow picking up to two
.addOptions(
new StringSelectMenuOptionBuilder()
.setLabel("Red").setValue("red").setDescription("Warm and loud"),
new StringSelectMenuOptionBuilder()
.setLabel("Blue").setValue("blue").setDescription("Calm and cool"),
new StringSelectMenuOptionBuilder()
.setLabel("Green").setValue("green").setDescription("Fresh"),
);
await message.channel.send({
content: "Pick one or two:",
components: [new ActionRowBuilder().addComponents(menu)],
});
}export async function onInteraction(interaction) {
if (!interaction.isStringSelectMenu()) return;
if (interaction.customId !== "colorpick:menu") return;
// values is an array of the selected option values, e.g. ["red", "green"]
await interaction.reply(
interaction.user.username + " picked: " + interaction.values.join(", ")
);
}Besides string menus there are entity pickers that need no options of their own: UserSelectMenuBuilder, RoleSelectMenuBuilder, ChannelSelectMenuBuilder (optionally restricted with setChannelTypes), and MentionableSelectMenuBuilder. Their handlers receive the picked IDs in interaction.values and are detected with isUserSelectMenu(), isRoleSelectMenu(), isChannelSelectMenu(), and isMentionableSelectMenu().
Modals: pop-up forms
A modal is a form with text inputs that opens in response to an interaction. You cannot open a modal from a plain message; the flow is button (or menu) first, modal second, submission third. That usually means two interactionCreate branches in one handler agent:
import {
ModalBuilder,
TextInputBuilder,
TextInputStyle,
LabelBuilder,
} from "discord";
export async function onInteraction(interaction) {
// Step 2: the button opens the modal.
if (interaction.isButton() && interaction.customId === "fb:open") {
const modal = new ModalBuilder()
.setCustomId("fb:form")
.setTitle("Send feedback");
const input = new TextInputBuilder()
.setCustomId("fb_text")
.setStyle(TextInputStyle.Paragraph) // Short = one line
.setPlaceholder("Tell us everything...")
.setMinLength(10)
.setMaxLength(1000)
.setRequired(true);
modal.addLabelComponents(
new LabelBuilder()
.setLabel("Your feedback")
.setDescription("What should we improve?")
.setTextInputComponent(input),
);
await interaction.showModal(modal);
return;
}
// Step 3: the submission arrives as a modal-submit interaction.
if (interaction.isModalSubmit() && interaction.customId === "fb:form") {
const text = interaction.fields.getTextInputValue("fb_text");
await interaction.reply("Thanks! We received: " + text.slice(0, 100));
}
}A separate messageCreate agent posts the entry button (fb:open), exactly as in Lesson 7. Expected behavior: clicking Send feedback opens the form; submitting it posts a confirmation with the first 100 characters typed.
Reading modal fields
interaction.fields.getTextInputValue("id")returns the typed string and throws if the field does not exist (a programming error you will see in the error channel).interaction.fieldsalso works as a plain object keyed by field custom ID, sointeraction.fields["fb_text"]is equivalent.- Each text input needs its own
LabelBuilderwrapper; a modal holds up to 5 inputs.
Common pitfalls
- Opening a modal after acknowledging.
showModal()must be the first response to the interaction; you cannot defer first. - Mismatch between min/max values and the handler. With
setMaxValues(2),interaction.valuescan hold two entries; handle the array, notvalues[0]alone. - Reusing the menu's customId for an option value. Options have values, the menu has the customId; the handler routes on the customId and reads
values. - Forgetting `setRequired(true)`. Optional empty inputs come back as empty strings; validate length yourself if it matters.
Exercise
Build a report flow: !report posts a button; the button opens a modal with two inputs (offender id, what happened); on submit, send an embed to a private moderators channel containing the reporter's name and both fields, then reply ephemerally Report filed.