Modals
Collect structured user input through modal dialogs with text fields, dropdowns, and validation.
Modals open form dialogs in response to button clicks or slash commands. They support text inputs, dropdowns, radio buttons, and server-side validation. Currently supported on Slack and Teams.
Open a modal
Modals are opened from action handlers or slash command handlers using event.openModal():
import { Modal, TextInput, Select, SelectOption } from "chat";
bot.onAction("feedback", async (event) => {
await event.openModal(
<Modal
callbackId="feedback_form"
title="Send Feedback"
submitLabel="Send"
closeLabel="Cancel"
notifyOnClose
>
<TextInput
id="message"
label="Your Feedback"
placeholder="Tell us what you think..."
multiline
/>
<Select id="category" label="Category" placeholder="Select a category">
<SelectOption label="Bug Report" value="bug" />
<SelectOption label="Feature Request" value="feature" />
<SelectOption label="General" value="general" />
</Select>
<TextInput
id="email"
label="Email (optional)"
placeholder="your@email.com"
optional
/>
</Modal>
);
});Components
Modal
The top-level container for the form.
| Prop | Type | Description |
|---|---|---|
callbackId | string | Identifier for matching submit/close handlers |
title | string | Modal title |
submitLabel | string (optional) | Submit button text (defaults to "Submit") |
closeLabel | string (optional) | Cancel button text (defaults to "Cancel") |
notifyOnClose | boolean (optional) | Fire onModalClose when user cancels |
privateMetadata | string (optional) | Custom context passed through to handlers |
TextInput
A text field for user input.
| Prop | Type | Description |
|---|---|---|
id | string | Field identifier (key in event.values) |
label | string | Field label |
placeholder | string (optional) | Placeholder text |
initialValue | string (optional) | Pre-filled value |
multiline | boolean (optional) | Render as textarea |
optional | boolean (optional) | Allow empty submission |
maxLength | number (optional) | Maximum character count |
Select
A dropdown for selecting a single option.
| Prop | Type | Description |
|---|---|---|
id | string | Field identifier |
label | string | Field label |
placeholder | string (optional) | Placeholder text |
initialOption | string (optional) | Pre-selected value |
optional | boolean (optional) | Allow empty submission |
ExternalSelect
A dropdown that loads its options dynamically from a handler as the user types. Useful for large or remote-backed option sets (people, tickets, records) where a static <Select> would be impractical. Slack-only.
| Prop | Type | Description |
|---|---|---|
id | string | Field identifier (key in event.values) |
label | string | Field label |
placeholder | string (optional) | Placeholder text |
minQueryLength | number (optional) | Minimum characters before the loader fires (Slack default: 3) |
optional | boolean (optional) | Allow empty submission |
Register the loader with onOptionsLoad:
import { ExternalSelect, Modal } from "chat";
bot.onAction("assign", async (event) => {
await event.openModal(
<Modal callbackId="assign_form" title="Assign to…">
<ExternalSelect
id="assignee"
label="Assignee"
placeholder="Search people"
minQueryLength={1}
/>
</Modal>
);
});
bot.onOptionsLoad("assignee", async (event) => {
const people = await peopleService.search(event.query);
return people.map((p) => ({ label: p.fullName, value: p.id }));
});
bot.onModalSubmit("assign_form", async (event) => {
const assigneeId = event.values.assignee;
// …
});The selected value arrives in event.values on submit just like a static <Select>.
Slack requires a response within 3 seconds for options requests. The adapter caps the loader at ~2.5s and returns an empty result on timeout — keep your loader fast (cache, prefetch, or narrow the query server-side).
Slack setup: ExternalSelect uses Slack's block_suggestion payload, which is dispatched to the Options Load URL. In your Slack app settings go to Interactivity & Shortcuts → Select Menus and set the Options Load URL to the same endpoint as your Interactivity Request URL (e.g. https://your-domain.com/api/webhooks/slack). Without this, typing into an external select will silently return no results.
RadioSelect
A radio button group for mutually exclusive options.
| Prop | Type | Description |
|---|---|---|
id | string | Field identifier |
label | string | Field label |
initialOption | string (optional) | Pre-selected value |
optional | boolean (optional) | Allow empty submission |
SelectOption
An option for Select or RadioSelect.
| Prop | Type | Description |
|---|---|---|
label | string | Display text |
value | string | Value passed to handler |
description | string (optional) | Help text below the label |
Handle submissions
Register a handler with onModalSubmit using the same callbackId:
bot.onModalSubmit("feedback_form", async (event) => {
const { message, category, email } = event.values;
// Validate input — return errors to show in the modal
if (!message || message.length < 5) {
return {
action: "errors",
errors: { message: "Feedback must be at least 5 characters" },
};
}
// Post confirmation to the original thread
if (event.relatedThread) {
await event.relatedThread.post(`Feedback received! Category: ${category}`);
}
// Update the message that triggered the modal
if (event.relatedMessage) {
await event.relatedMessage.edit("Feedback submitted!");
}
// Return nothing (or { action: "close" }) to close the modal
});Response types
| Response | Description |
|---|---|
undefined or { action: "close" } | Close the current view (goes back one level in the stack) |
{ action: "clear" } | Close all views and dismiss the modal entirely |
{ action: "errors", errors: { fieldId: "message" } } | Show validation errors on specific fields |
{ action: "update", modal: ModalElement } | Replace the modal content |
{ action: "push", modal: ModalElement } | Push a new modal view onto the stack |
ModalSubmitEvent
| Property | Type | Description |
|---|---|---|
callbackId | string | Modal identifier |
viewId | string | Platform view ID |
values | Record<string, string> | Form field values keyed by input id |
user | Author | The user who submitted |
privateMetadata | string (optional) | Custom context from the Modal component |
relatedThread | Thread (optional) | Thread where the modal was triggered |
relatedMessage | SentMessage (optional) | Message with the button that opened the modal |
relatedChannel | Channel (optional) | Channel where the modal was triggered (from slash commands) |
adapter | Adapter | The platform adapter |
raw | unknown | Platform-specific payload |
Handle cancellation
Optionally handle when users cancel a modal. Requires notifyOnClose on the Modal component:
bot.onModalClose("feedback_form", async (event) => {
console.log(`${event.user.userName} cancelled the feedback form`);
if (event.relatedThread) {
await event.relatedThread.post("No worries, let us know if you change your mind!");
}
});Pass context with privateMetadata
Use privateMetadata to carry context from the button click through to the submit handler:
bot.onAction("report", async (event) => {
await event.openModal(
<Modal
callbackId="report_form"
title="Report Bug"
submitLabel="Submit"
privateMetadata={JSON.stringify({
reportType: event.value,
threadId: event.threadId,
})}
>
<TextInput id="title" label="Bug Title" />
<TextInput id="steps" label="Steps to Reproduce" multiline />
</Modal>
);
});
bot.onModalSubmit("report_form", async (event) => {
const metadata = event.privateMetadata
? JSON.parse(event.privateMetadata)
: {};
console.log(metadata.reportType); // "bug"
});