LogoRobo.js

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.

src/commands/feedback.ts
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)
}
src/commands/feedback.js
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:

  1. Set sage: false on the command config (recommended). This disables auto-defer, giving you full control over the interaction response.

  2. Show the modal synchronously before any await. Since Sage's defer buffer defaults to 250ms, calling showModal() 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

StyleConstantDescription
ShortTextInputStyle.ShortSingle-line input
ParagraphTextInputStyle.ParagraphMulti-line textarea

Text input methods

MethodDescription
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():

src/events/interactionCreate/feedback-submit.ts
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 })
}
src/events/interactionCreate/feedback-submit.js
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:

src/commands/report.ts
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)
}
src/commands/report.js
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)
}

A common pattern: a command sends a button, and clicking the button opens a modal. This requires two files.

feedback.tsSends the button
feedback-modal.tsShows the modal
feedback-submit.tsHandles submission

Command that sends the button:

src/commands/feedback.ts
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] }
}
src/commands/feedback.js
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:

src/events/interactionCreate/feedback-modal.ts
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)
}
src/events/interactionCreate/feedback-modal.js
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)
}

The same pattern works with select menu triggers:

src/events/interactionCreate/report-modal.ts
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)
}
src/events/interactionCreate/report-modal.js
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

ConstraintLimit
Text inputs per modal5
Component types allowedText inputs only (no buttons, selects, etc.)
Submission timeout15 minutes
Custom ID length100 characters
Modal title length45 characters

Next steps

On this page