Buttons & Select Menus
Exhaustive reference for Buttons, Select Menus, Components V2, and Medusa's internal Discord UI API.
Medusa provides a unified internal API for building, resolving, tokenizing, and dispatching Discord message components. Components are built with the this.medusa.components builder and resolved and dispatched through the Embeds service (this.medusa.embeds). The builder produces plain component trees with named methods so module code never hardcodes Discord's raw component type integers.
Never use numeric integers for button styles (such as style: 2 or style: 1). Medusa requires string literals ("primary", "secondary", "success", "danger", "link"). Medusa's internal component resolver automatically normalizes string literals to Discord's underlying numeric codes during dispatch.
Official modules use gray "secondary" buttons (or "link" for URLs) everywhere. Only user-configured button colour options pick other styles.
Component Architecture
Discord components in Medusa operate in two modes:
- Standard Components (V1): Up to 5 Action Rows (
type: 1), each holding up to 5 buttons (type: 2) or 1 select menu (type: 3). - Components V2 (
MessageFlags.IsComponentsV2): Modern Discord card layouts utilizing Containers (type: 17), Sections (type: 9), Text Displays (type: 10), Separators (type: 14), Thumbnails (type: 11), and Media Galleries (type: 12), in addition to Action Rows and Section button accessories.
Build trees with this.medusa.components (see Builder API), then let the Embeds service resolve them. Every tree passes through this.medusa.embeds.resolveComponent(comp, tokens) (or this.medusa.components.resolve / payload / send, which call it) to ensure token replacement, custom emoji translation, style conversion, and accent color injection. this.medusa.embeds.buildComponents(buttons, selectMenu, rawComponents) remains available for assembling standard button rows.
Builder API (this.medusa.components)
Every building block (handlers, commands, events, routes) receives this.medusa.components, a builder that returns plain component trees. Each method maps to a Discord component type so module code never writes raw integers like type: 17 or type: 10. The trees flow unchanged into resolve, payload, send, or this.medusa.embeds.send(...), where tokens, styles, emojis, and accent colors are resolved.
Builder Reference
| Method | Signature | Produces |
|---|---|---|
container | ({ accent?, components?, spoiler?, id? }) => object | Container (type: 17). accent is accepted for design purposes, but the resolver always applies the global ACCENT_COLOUR. |
section | ({ components?, accessory? }) => object | Section (type: 9). String components are wrapped as text displays; accessory is a button or thumbnail. |
text | (content: string) => object | Text Display (type: 10). |
separator | ({ divider?, spacing? }) => object | Separator (type: 14). Defaults to divider: true, spacing: 1. |
thumbnail | (url: string, { description?, spoiler? }) => object | Thumbnail (type: 11), used as a section accessory. |
gallery | (items: Array<string | object>) => object | Media Gallery (type: 12). Items are URL strings or { url, description?, spoiler? }. |
file | (url: string, { spoiler? }) => object | File (type: 13). |
row | (components: Array) => object | Action Row (type: 1) holding buttons or a select menu. |
button | ({ style?, label?, custom_id?, url?, emoji?, disabled? }) => object | Button (type: 2) with a string style. |
select | ({ custom_id?, placeholder?, options?, disabled?, min?, max? }) => object | String Select Menu (type: 3). |
resolve | (components, tokens?) => Array | Resolves one tree or an array of trees through the Embeds resolver, returning the dispatch-ready array. |
payload | (components, { tokens?, content?, silent? }) => object | Resolves and wraps a tree into a send-ready message object with MessageFlags.IsComponentsV2 applied. |
send | (channelId, components, options?) => Promise<Message> | Builds a payload and posts it to a channel. |
Building a V2 Card
const banner = this.medusa.components.container({
accent: "#ef4444",
components: [
this.medusa.components.section({
components: [`### ${this.medusa.modules.lang.phrase("anti-scam", "messages", "bannerTitle")}\n\n${this.medusa.modules.lang.phrase("anti-scam", "messages", "bannerBody")}`],
accessory: this.medusa.components.thumbnail("%brand_icon%")
}),
this.medusa.components.separator(),
this.medusa.components.row([
this.medusa.components.button({
style: "success",
label: this.medusa.modules.lang.phrase("example", "messages", "confirm"),
emoji: "%custom_emoji_check%",
custom_id: "medusa:button:anti-scam:confirm"
})
])
]
});
await channel.send(this.medusa.components.payload(banner));payload applies MessageFlags.IsComponentsV2 and resolves the whole tree, so handlers no longer set the flag or call resolveComponent by hand. Pass { silent: true } to suppress notifications, { content } for a leading message, and { tokens } for custom %token% substitutions.
Buttons (type: 2)
Buttons allow users to trigger bot workflows, confirm actions, or open external links.
Button Schema
type ButtonStyle = "primary" | "secondary" | "success" | "danger" | "link";
interface ButtonComponent {
type: 2;
style: ButtonStyle;
label?: string;
custom_id?: string;
url?: string;
emoji?: string | { id?: string; name: string; animated?: boolean };
disabled?: boolean;
}| Field | Type | Description |
|---|---|---|
type | 2 | Discord component type for Buttons. |
style | string | Required. String literal style: "primary", "secondary", "success", "danger", or "link". |
label | string | Display text on the button (max 80 characters). Automatically trimmed. Defaults to URL or Button N if omitted. |
custom_id | string | Interaction identifier returned when the button is clicked. Required unless style: "link". |
url | string | Target HTTP/HTTPS destination URL. Required when style: "link". Must match /^https?:\/\//i. |
emoji | string | object | Custom bot emoji token (e.g. %custom_emoji_hammer%), unicode emoji, or { id, name, animated }. |
disabled | boolean | When true, disables user interaction and renders the button visually dimmed. |
Button Styles
| Style Literal | Underlying Discord Code | Appearance | Usage & Requirements |
|---|---|---|---|
"primary" | 1 | Blurple / Accent | Primary call-to-action button. Requires custom_id. |
"secondary" | 2 | Neutral Grey | Default secondary button or neutral navigation. Requires custom_id. |
"success" | 3 | Green | Positive confirmations, approvals, and submits. Requires custom_id. |
"danger" | 4 | Red | Destructive actions, cancellations, and kicks/bans. Requires custom_id. |
"link" | 5 | Grey with External Icon | External web navigation. Requires valid url; custom_id is forbidden. |
Button Definitions in Modules
Action Buttons
const confirmButton = {
type: 2,
style: "success",
label: this.medusa.modules.lang.phrase("example", "messages", "confirm"),
emoji: "%custom_emoji_check%",
custom_id: "medusa:button:core:confirm"
};
const cancelButton = {
type: 2,
style: "secondary",
label: this.medusa.modules.lang.phrase("example", "messages", "cancel"),
emoji: "%custom_emoji_cross%",
custom_id: "medusa:button:core:cancel"
};Link Button
const websiteButton = {
type: 2,
style: "link",
url: "https://zerodev.ca",
label: "Zero Development",
emoji: "%custom_emoji_external%"
};Metric / Counter Button (Disabled)
const counterButton = {
type: 2,
style: "secondary",
label: this.medusa.modules.lang.phrase("anti-scam", "messages", "bannedButton", { count: String(bannedTotal) }),
emoji: "%custom_emoji_hammer%",
disabled: true,
custom_id: "honeypot_banned_display"
};Select Menus (type: 3)
String Select Menus provide an interactive dropdown list allowing members to pick an item from up to 25 choices.
Select Menu Schema
interface SelectMenuOption {
label: string;
value: string;
description?: string;
emoji?: string | { id?: string; name: string; animated?: boolean };
default?: boolean;
}
interface SelectMenuComponent {
type: 3;
custom_id?: string;
placeholder?: string;
disabled?: boolean;
options: SelectMenuOption[];
}| Field | Type | Description |
|---|---|---|
type | 3 | Discord component type for String Select Menus. |
custom_id | string | Event identifier. Defaults to "select_menu" if omitted. |
placeholder | string | Descriptive prompt displayed before an option is selected (max 150 characters). |
disabled | boolean | When true, prevents dropdown expansion and interactions. |
options | Array | List of 1 to 25 select menu options. |
Select Menu Option Schema
| Field | Type | Description |
|---|---|---|
label | string | Required. Item title (max 100 characters). Auto-falls back to Option N. |
value | string | Required. Unique value returned upon selection (max 100 characters). Auto-falls back to opt_N. |
description | string | Optional secondary description text displayed below the title (max 100 characters). |
emoji | string | object | Custom bot emoji token (e.g. %custom_emoji_bell%), unicode character, or emoji object. |
default | boolean | When true, this option is pre-selected by default. |
Select Menu Example
const categoryMenu = {
type: 3,
custom_id: "medusa:select:help:category",
placeholder: this.medusa.modules.lang.phrase("example", "messages", "selectCategory"),
options: [
{
label: "Moderation",
value: "moderation",
description: "Server moderation and security utilities",
emoji: "%custom_emoji_gavel%",
default: false
},
{
label: "Economy",
value: "economy",
description: "Currency, banking, and marketplace features",
emoji: "%custom_emoji_coin%",
default: false
}
]
};Internal API (this.medusa.embeds)
Every building block (handlers, commands, events, routes) receives access to Medusa's internal Embeds service through this.medusa.embeds.
Method Reference
| Method | Signature | Description |
|---|---|---|
resolveComponent | (comp: object, tokens?: object) => object | Recursively resolves an entire component tree: converts style string literals to Discord numbers, resolves %custom_emoji_<name>% placeholders, performs token string substitution, and normalizes hex accent colors ("#ef4444"). |
buildComponents | (buttons?: Array, selectMenu?: object, rawComponents?: Array) => Array | Assembles button arrays into Action Rows in groups of 5 (max 5 rows) and appends select menus in dedicated rows. |
buttonStyleNum | (style: string | number) => number | Converts style literals ("link" $\to 5$, "secondary" $\to 2$, "success" $\to 3$, "danger" $\to 4$, default $\to 1$). |
parseButtonEmoji | (token: any) => object | null | Resolves custom emoji tags, %custom_emoji_<name>% tokens, and Unicode characters into { id, name, animated } or { name }. |
isComponentsV2 | (components: Array) => boolean | Checks whether any component in an array uses V2 component types (17, 9, 10, 12, 13, 14, 15). |
send | (channelId: string, content?: string, template?: object, tokens?: object, buttons?: Array, selectMenu?: object, components?: Array, flags?: number, embeds?: Array) => Promise<Message> | Dispatches a complete Discord message with automatic V1/V2 component resolution, token interpolation, and notification suppression. |
saveMessage | (message: object) => Promise<object> | Persists a message template with buttons and components into settings storage ("MESSAGES"). |
deleteMessage | (id: string) => Promise<Array<object>> | Deletes a saved message template from storage by ID. |
messages | () => Array<object> | Retrieves all saved message templates from settings storage. |
resolve | (template: object, tokens?: object, options?: object) => EmbedBuilder | Fills tokens and custom emojis in an embed template and returns a decorated EmbedBuilder. A token whose value is an array is a list token: template.lists[token] is filled once per array entry (each entry is its own token object) and the joined result replaces %token%. See Embed Fields. |
fill | (value: any, tokens?: object) => any | Recursively interpolates %token% and <token> values and custom emojis across strings, arrays, and nested objects. |
resolveComponent(comp, tokens)
The primary resolution pipeline for all components. It recursively handles all Discord component types:
- Type 17 & 15 (Container / Spacer):
- Recursively resolves all child
components. - Forces
accent_colorto the globalACCENT_COLOURsetting as a 24-bit decimal integer, ignoring any value set on the container.
- Recursively resolves all child
- Type 9 (Section):
- Recursively resolves child components.
- Normalizes and resolves
accessorycomponents (Buttontype: 2or Thumbnailtype: 11).
- Type 10 (Text Display):
- Interpolates tokens inside
content.
- Interpolates tokens inside
- Type 11 (Thumbnail):
- Fills tokens in
media.urlanddescription.
- Fills tokens in
- Type 12 (Media Gallery):
- Iterates through
items, resolving media URLs and descriptions.
- Iterates through
- Type 14 (Separator):
- Preserves divider and spacing layout rules.
- Type 1 (Action Row):
- Resolves all child buttons (
type: 2) by convertingstyleviabuttonStyleNum, resolvingemoji, and substituting tokens inlabelandurl. - Resolves all child select menus (
type: 3) by fillingplaceholderand optionlabel,description, andemoji.
- Resolves all child buttons (
const resolvedContainer = this.medusa.embeds.resolveComponent({
type: 17,
accent_color: "#22c55e",
components: [
{
type: 10,
content: "Welcome to **%server_name%**!"
},
{
type: 14,
divider: true,
spacing: 1
},
{
type: 1,
components: [
{
type: 2,
style: "primary",
label: "Get Started",
emoji: "%custom_emoji_rocket%",
custom_id: "medusa:button:welcome:start"
}
]
}
]
}, { server_name: guild.name });buildComponents(buttons, selectMenu, rawComponents)
When constructing standard messages without manual row grouping, buildComponents packages button arrays automatically:
const rows = this.medusa.embeds.buildComponents(
[
{ type: 2, style: "secondary", label: "Previous", custom_id: "medusa:button:page:prev" },
{ type: 2, style: "secondary", label: "Next", custom_id: "medusa:button:page:next" }
],
{
custom_id: "medusa:select:page:jump",
placeholder: "Jump to page...",
options: [
{ label: "Page 1", value: "1" },
{ label: "Page 2", value: "2" }
]
}
);- Groups buttons into blocks of 5 per Action Row (up to 25 buttons total).
- Places the select menu into its own row following the button rows.
- Filters out broken link buttons lacking a valid URL.
- Auto-generates fallback
custom_idvalues (btn_1,btn_2, etc.) if omitted on action buttons.
Components V2 Layouts
Medusa natively supports modern Discord Components V2 cards. Build the card with this.medusa.components and dispatch it with payload, which applies MessageFlags.IsComponentsV2 and resolves the tree.
export class HoneypotHandler extends Handlers {
button(bannedCount) {
return this.medusa.components.button({
style: "secondary",
label: this.medusa.modules.lang.phrase("anti-scam", "messages", "bannedButton", { count: String(bannedCount) }),
emoji: "%custom_emoji_hammer%",
disabled: true,
custom_id: "honeypot_banned_display"
});
}
banner(bannedCount) {
return this.medusa.components.container({
accent: "#ef4444",
components: [
this.medusa.components.text(`### ${this.medusa.modules.lang.phrase("anti-scam", "messages", "bannerTitle")}\n\n${this.medusa.modules.lang.phrase("anti-scam", "messages", "bannerBody")}`),
this.medusa.components.separator(),
this.medusa.components.row([this.button(bannedCount)])
]
});
}
async postBanner(channel, guild) {
const bannedTotal = await this.count(guild.id);
return channel.send(this.medusa.components.payload(this.banner(bannedTotal)));
}
}Interaction Routing & Custom IDs
To prevent naming collisions between modules, Medusa enforces a namespaced pattern for all interaction custom IDs:
medusa:<component_type>:<module_name>:<action>[:parameters]- Buttons:
medusa:button:<module>:<action>[:params](e.g.medusa:button:emojilocker:menu:lock,medusa:button:ticket:claim:12345). - Select Menus:
medusa:select:<module>:<action>(e.g.medusa:select:roles:assign). - Modals:
medusa:modal:<module>:<action>(e.g.medusa:modal:ticket:reason).
Interaction Listener in Handlers
export class TicketHandler extends Handlers {
async handleInteraction(interaction) {
if (!interaction.isButton() && !interaction.isStringSelectMenu()) return;
const parts = interaction.customId.split(":");
const scope = parts[0];
const type = parts[1];
const module = parts[2];
const action = parts[3];
if (scope !== "medusa" || module !== "ticket") return;
switch (action) {
case "close": {
await interaction.update({
embeds: [this.medusa.embeds.warn(this.medusa.modules.lang.phrase("ticket", "messages", "ticketClosing"))],
components: []
});
break;
}
case "claim": {
const ticketId = parts[4];
await interaction.reply({
embeds: [this.medusa.embeds.success(this.medusa.modules.lang.phrase("ticket", "messages", "ticketClaimed", { id: ticketId }))],
flags: MessageFlags.Ephemeral
});
break;
}
default:
break;
}
}
}Global Placeholders in Components
When components are resolved via this.medusa.embeds.resolveComponent(comp, tokens) or this.medusa.embeds.send(...), the following tokens are automatically populated inside button labels, URLs, select menu options, and text contents:
| Token | Replacement Value |
|---|---|
%server% / %server_name% | Current Discord server name. |
%server_icon% / %guild_icon% | Server icon URL. |
%user_avatar% | Interaction user or bot avatar URL. |
%bot_avatar% | Discord application bot avatar URL. |
%brand_icon% | Configured brand icon URL (CUSTOM_ICON_URL). |
%name% | Configured dashboard branding name (DASHBOARD_NAME). |
%version% | Active Medusa bot version. |
%custom_emoji_<name>% | Custom emoji string tag (e.g. <:check:123456789>). |
<custom_token> | Any custom key provided in the tokens object parameter. |