Modals
Modals are pop-up forms with up to 5 labeled components: text inputs, selects, radio groups, checkboxes, and file uploads. Build them with ModalBuilder and read submissions with getField.
Modals are pop-up forms shown in response to a command or component interaction. Build them with ModalBuilder and read submissions from ModalSubmitInteraction.
The modal model
A modal holds up to 5 top-level components. Each one is either a Label wrapping exactly one interactive component (text input, any select, file upload, radio group, checkbox group, or checkbox) or a top-level Text Display (markdown body text).
The older ActionRow + TextInput shape is deprecated by Discord. ModalBuilder emits the Label-wrapped form for everything, including addTextInput, which keeps its old options and gained an optional description. Both shapes parse identically on submit, so existing handlers keep working.
Every add* method takes a label (max 45 chars, the Label heading) and an optional description (max 100 chars, rendered under it).
Building
import { ModalBuilder, TextInputStyle } from 'athena';
const modal = new ModalBuilder('Feedback', 'feedback-form');
modal.addTextInput({ custom_id: 'subject', style: TextInputStyle.Short, label: 'Subject', required: true, max_length: 100 });
modal.addTextInput({ custom_id: 'body', style: TextInputStyle.Paragraph, label: 'What happened', description: 'Include as much detail as you can', required: true, max_length: 4000 });
await interaction.createModal(modal.toJSON());Constructor: title (max 45 chars) and custom_id (max 100 chars, reused on submission).
Component reference
| Method | Notes |
|---|---|
addTextInput | style is TextInputStyle.Short or Paragraph; min_length / max_length (hard cap 4000), required, value (pre-fill), placeholder. |
addStringSelect | options (1-25 of { label, value, description?, default? }), placeholder, min_values / max_values. |
addUserSelect / addRoleSelect / addMentionableSelect / addChannelSelect | Entity selects; addChannelSelect adds channel_types to restrict pickable types. Submitted IDs resolve via the resolved maps below. |
addRadioGroup | Single choice from 2-10 options ({ value, label, description?, default? }). Submits a single nullable value. |
addCheckboxGroup | Multiple choice from 1-10 options; min_values / max_values. Submits a values array (empty when nothing is checked). |
addCheckbox | Single boolean; default pre-checks it. Submits a boolean value. Cannot be required; for a required confirmation, use a one-option checkbox group with required: true. |
addFileUpload | min_values / max_values (0-10 files), required. Submits attachment IDs in values; resolve via resolvedAttachments. |
addTextDisplay | Top-level markdown text; takes a single content string and no Label. |
All five select types support required, which defaults to true in modals. Setting disabled on a select inside a modal is an error.
Showing a modal
A modal must be the first response to an interaction; you cannot defer first. It works from a command or a component interaction.
async handleCommand(context, interaction) {
await interaction.createModal(modal.toJSON());
}Handling submissions
A submission fires interactionCreate with a ModalSubmitInteraction. Look fields up by custom_id with getField(customID), which returns a ModalField | null (interaction.fields still gives you every field if you prefer iterating):
| Property | Notes |
|---|---|
value | First submitted value as a string (undefined when nothing was submitted). |
values | All submitted values as a string array (selects, checkbox groups, file uploads). |
rawValue | The raw single value: a string for text inputs, a nullable string for radio groups, a boolean for checkboxes. |
checked | true when this is a checked checkbox (false for any other field). |
| Type guards | isTextInput, isStringSelect, isUserSelect, isRoleSelect, isMentionableSelect, isChannelSelect, isRadioGroup, isCheckboxGroup, isCheckbox, isFileUpload. |
Entity selects and file uploads submit IDs; the full objects arrive in resolved maps on the interaction, keyed by ID: resolvedUsers, resolvedMembers (guilds only), resolvedRoles, resolvedChannels, and resolvedAttachments.
const modal = new ModalBuilder('Report a user', 'report')
.addUserSelect({ custom_id: 'target', label: 'Who are you reporting?' })
.addRadioGroup({
custom_id: 'category',
label: 'Category',
options: [
{ value: 'spam', label: 'Spam' },
{ value: 'abuse', label: 'Abuse', description: 'Harassment or threats' },
],
})
.addFileUpload({ custom_id: 'evidence', label: 'Evidence', max_values: 3, required: false });
// In the command handler:
await interaction.createModal(modal.toJSON());
// On submission:
client.on('interactionCreate', async (interaction) => {
if (!interaction.isModal() || interaction.data.custom_id !== 'report') return;
const targetID = interaction.getField('target')?.value;
const target = targetID ? interaction.resolvedUsers.get(targetID) : undefined;
const category = interaction.getField('category')?.value; // 'spam' | 'abuse' | undefined
const evidence = (interaction.getField('evidence')?.values ?? [])
.map((id) => interaction.resolvedAttachments.get(id)!);
await interaction.createMessage({ content: 'Report received.', flags: MessageFlags.Ephemeral });
});With the commands framework, implement handleModal and route by custom_id prefix.
Tips
- Encode any context you need into the
custom_id(for examplefeedback-form:<userID>); modals carry no other state across the submit boundary. - You cannot show a second modal in response to a modal submission. Defer or send a normal message instead.
- Five top-level components is the ceiling, and a Text Display counts against it the same as any input.