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

MethodNotes
addTextInputstyle is TextInputStyle.Short or Paragraph; min_length / max_length (hard cap 4000), required, value (pre-fill), placeholder.
addStringSelectoptions (1-25 of { label, value, description?, default? }), placeholder, min_values / max_values.
addUserSelect / addRoleSelect / addMentionableSelect / addChannelSelectEntity selects; addChannelSelect adds channel_types to restrict pickable types. Submitted IDs resolve via the resolved maps below.
addRadioGroupSingle choice from 2-10 options ({ value, label, description?, default? }). Submits a single nullable value.
addCheckboxGroupMultiple choice from 1-10 options; min_values / max_values. Submits a values array (empty when nothing is checked).
addCheckboxSingle 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.
addFileUploadmin_values / max_values (0-10 files), required. Submits attachment IDs in values; resolve via resolvedAttachments.
addTextDisplayTop-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):

PropertyNotes
valueFirst submitted value as a string (undefined when nothing was submitted).
valuesAll submitted values as a string array (selects, checkbox groups, file uploads).
rawValueThe raw single value: a string for text inputs, a nullable string for radio groups, a boolean for checkboxes.
checkedtrue when this is a checked checkbox (false for any other field).
Type guardsisTextInput, 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 example feedback-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.