Modals
Popup forms for collecting text input from users.
Modals are popup forms that collect text input from users. They can be triggered from any interaction -- commands, buttons, or select menus. Each modal supports up to 5 text inputs.
Creating modals
Build a modal with ModalBuilder and add text fields using TextInputBuilder. Each text input must be wrapped in its own ActionRowBuilder.
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'
import type { CommandConfig } from '@robojs/discordjs'
import type { ChatInputCommandInteraction } from 'discord.js'
export const config: CommandConfig = {
description: 'Submit feedback',
sage: false
}
export default async (interaction: ChatInputCommandInteraction) => {
const modal = new ModalBuilder()
.setCustomId('feedback-modal')
.setTitle('Submit Feedback')
const input = new TextInputBuilder()
.setCustomId('feedback-input')
.setLabel('Your feedback')
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder('Tell us what you think...')
.setRequired(true)
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input))
await interaction.showModal(modal)
}import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'
export const config = {
description: 'Submit feedback',
sage: false
}
export default async (interaction) => {
const modal = new ModalBuilder()
.setCustomId('feedback-modal')
.setTitle('Submit Feedback')
const input = new TextInputBuilder()
.setCustomId('feedback-input')
.setLabel('Your feedback')
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder('Tell us what you think...')
.setRequired(true)
modal.addComponents(new ActionRowBuilder().addComponents(input))
await interaction.showModal(modal)
}Sage mode and modals
interaction.showModal() must be called before any reply or defer. Sage's auto-defer conflicts with this because it sends a deferred reply, which consumes the interaction response.
Two solutions:
-
Set
sage: falseon the command config (recommended). This disables auto-defer, giving you full control over the interaction response. -
Show the modal synchronously before any
await. Since Sage's defer buffer defaults to 250ms, callingshowModal()immediately (before any async work) fires before the defer timer.
// Option 1: Disable Sage (recommended)
export const config = { sage: false }
// Option 2: Show modal before any await
export default (interaction) => {
interaction.showModal(modal) // No await -- fires immediately
}Text input styles
| Style | Constant | Description |
|---|---|---|
| Short | TextInputStyle.Short | Single-line input |
| Paragraph | TextInputStyle.Paragraph | Multi-line textarea |
Text input methods
| Method | Description |
|---|---|
setCustomId(id) | Unique identifier for the input (max 100 characters) |
setLabel(text) | Label displayed above the input |
setStyle(style) | TextInputStyle.Short or TextInputStyle.Paragraph |
setMinLength(n) | Minimum character count |
setMaxLength(n) | Maximum character count |
setPlaceholder(text) | Greyed-out hint text |
setRequired(bool) | Whether the field must be filled |
setValue(default) | Pre-filled default value |
Handling submissions
Listen for modal submissions with an interactionCreate event handler using isModalSubmit(). Retrieve field values with fields.getTextInputValue():
import type { Interaction } from 'discord.js'
export default async (interaction: Interaction) => {
if (!interaction.isModalSubmit()) return
if (interaction.customId !== 'feedback-modal') return
const feedback = interaction.fields.getTextInputValue('feedback-input')
await interaction.reply({ content: `Thanks for your feedback: "${feedback}"`, ephemeral: true })
}export default async (interaction) => {
if (!interaction.isModalSubmit()) return
if (interaction.customId !== 'feedback-modal') return
const feedback = interaction.fields.getTextInputValue('feedback-input')
await interaction.reply({ content: `Thanks for your feedback: "${feedback}"`, ephemeral: true })
}Multiple text inputs
Modals can include up to 5 text inputs, each in its own action row:
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'
import type { CommandConfig } from '@robojs/discordjs'
import type { ChatInputCommandInteraction } from 'discord.js'
export const config: CommandConfig = {
description: 'Submit a bug report',
sage: false
}
export default async (interaction: ChatInputCommandInteraction) => {
const modal = new ModalBuilder()
.setCustomId('bug-report')
.setTitle('Bug Report')
const titleInput = new TextInputBuilder()
.setCustomId('bug-title')
.setLabel('Title')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(100)
const descriptionInput = new TextInputBuilder()
.setCustomId('bug-description')
.setLabel('Description')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
.setPlaceholder('Steps to reproduce...')
const stepsInput = new TextInputBuilder()
.setCustomId('bug-steps')
.setLabel('Expected vs Actual')
.setStyle(TextInputStyle.Paragraph)
.setRequired(false)
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(titleInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(descriptionInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(stepsInput)
)
await interaction.showModal(modal)
}import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'
export const config = {
description: 'Submit a bug report',
sage: false
}
export default async (interaction) => {
const modal = new ModalBuilder()
.setCustomId('bug-report')
.setTitle('Bug Report')
const titleInput = new TextInputBuilder()
.setCustomId('bug-title')
.setLabel('Title')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(100)
const descriptionInput = new TextInputBuilder()
.setCustomId('bug-description')
.setLabel('Description')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
.setPlaceholder('Steps to reproduce...')
const stepsInput = new TextInputBuilder()
.setCustomId('bug-steps')
.setLabel('Expected vs Actual')
.setStyle(TextInputStyle.Paragraph)
.setRequired(false)
modal.addComponents(
new ActionRowBuilder().addComponents(titleInput),
new ActionRowBuilder().addComponents(descriptionInput),
new ActionRowBuilder().addComponents(stepsInput)
)
await interaction.showModal(modal)
}Modal from button
A common pattern: a command sends a button, and clicking the button opens a modal. This requires two files.
Command that sends the button:
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'
export default () => {
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('open-feedback')
.setLabel('Give Feedback')
.setStyle(ButtonStyle.Primary)
)
return { content: 'We value your input!', components: [row] }
}import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'
export default () => {
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open-feedback')
.setLabel('Give Feedback')
.setStyle(ButtonStyle.Primary)
)
return { content: 'We value your input!', components: [row] }
}Event handler that shows the modal on click:
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'
import type { Interaction } from 'discord.js'
export default async (interaction: Interaction) => {
if (!interaction.isButton() || interaction.customId !== 'open-feedback') return
const modal = new ModalBuilder()
.setCustomId('feedback-modal')
.setTitle('Submit Feedback')
const input = new TextInputBuilder()
.setCustomId('feedback-input')
.setLabel('Your feedback')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input))
await interaction.showModal(modal)
}import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'
export default async (interaction) => {
if (!interaction.isButton() || interaction.customId !== 'open-feedback') return
const modal = new ModalBuilder()
.setCustomId('feedback-modal')
.setTitle('Submit Feedback')
const input = new TextInputBuilder()
.setCustomId('feedback-input')
.setLabel('Your feedback')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
modal.addComponents(new ActionRowBuilder().addComponents(input))
await interaction.showModal(modal)
}Modal from select menu
The same pattern works with select menu triggers:
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'
import type { Interaction } from 'discord.js'
export default async (interaction: Interaction) => {
if (!interaction.isStringSelectMenu() || interaction.customId !== 'report-type') return
const reportType = interaction.values[0]
const modal = new ModalBuilder()
.setCustomId(`report-${reportType}`)
.setTitle(`${reportType} Report`)
const input = new TextInputBuilder()
.setCustomId('report-details')
.setLabel('Details')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input))
await interaction.showModal(modal)
}import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'
export default async (interaction) => {
if (!interaction.isStringSelectMenu() || interaction.customId !== 'report-type') return
const reportType = interaction.values[0]
const modal = new ModalBuilder()
.setCustomId(`report-${reportType}`)
.setTitle(`${reportType} Report`)
const input = new TextInputBuilder()
.setCustomId('report-details')
.setLabel('Details')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
modal.addComponents(new ActionRowBuilder().addComponents(input))
await interaction.showModal(modal)
}Limitations
| Constraint | Limit |
|---|---|
| Text inputs per modal | 5 |
| Component types allowed | Text inputs only (no buttons, selects, etc.) |
| Submission timeout | 15 minutes |
| Custom ID length | 100 characters |
| Modal title length | 45 characters |
