Skip to main content

Components v2

Components v2 is Discord's layout-first message format. A v2 message is built entirely from layout and media components instead of content + embeds. Arcscord sets the required IS_COMPONENTS_V2 flag automatically.

The examples in this guide import the helpers from arcscord, which re-exports the complete Components V2 API. Every valid nesting point accepts Discord.js component data, official builders, and raw discord-api-types component objects.

Arcscord and standalone usage​

In an Arcscord bot, import the helpers directly from the framework. You do not need to install @arcscord/components separately:

import { actionRow, container, text, v2Message } from "arcscord";

The same API also works without the Arcscord framework. Install the standalone package alongside Discord.js and change only the import source:

pnpm add @arcscord/components discord.js
import { ButtonBuilder, ButtonStyle } from "discord.js";
import { actionRow, v2Message } from "@arcscord/components";

await interaction.reply(v2Message(
"Standalone Components V2",
actionRow(
new ButtonBuilder()
.setCustomId("confirm")
.setLabel("Confirm")
.setStyle(ButtonStyle.Primary),
),
));

Both import paths produce the same Discord.js-compatible payloads. The standalone package depends only on discord-api-types at runtime and uses discord.js as a peer dependency.

Validation errors​

Every Components V2 helper serializes, normalizes, and validates its result before returning it, including nested components. You can process external data explicitly with validateTextDisplay, validateActionRow, validateSection, validateContainer, validateMessageComponent, or validateV2Message; successful calls return newly constructed Discord.js camelCase data.

Invalid direct calls throw MessageComponentValidationError, which exposes the failed rule and exact component path, whether the helper is imported from @arcscord/components or arcscord. If that error is thrown by a command, component, event, or middleware, Arcscord converts the execution defect into an ArcscordError with code MESSAGE_COMPONENT_VALIDATION_FAILED and retains the validation error as cause. Call normalizeArcscordError(error) for the same conversion outside the execution pipeline.

An unrecognized discriminator uses the neutral unexpected-component-type rule. A recognized Discord component used at an invalid nesting location uses component-placement; neither rule guesses whether the value came from a typo or a newer API.

At message level, validation rejects incompatible legacy body fields, missing IS_COMPONENTS_V2, duplicate non-zero component IDs, duplicate interactive custom IDs, and payloads containing more than 40 components across the full nested tree.

v2Message()​

Entry point for a v2 message. Wraps layout components and optional message-level options.

import { v2Message } from "arcscord";

ctx.reply(v2Message(
"Hello from Components v2!", // plain string — equivalent to text()
));

Message-level options​

Pass an options object as the first argument (before layout children) to set message-level flags:

import { MessageFlags } from "discord.js";

ctx.reply(v2Message(
{ files: [{ attachment: buffer, name: "output.txt" }], flags: MessageFlags.Ephemeral },
"See the attached file.",
))
OptionTypeDescription
filesAttachmentData[]File attachments. Reference them in file() via attachment://filename.
flagsMessageFlagsMessage flags. Use MessageFlags.Ephemeral to make the message visible only to the user who triggered it. IS_COMPONENTS_V2 is always added automatically.
ttsbooleanText-to-speech.
allowedMentionsAllowedMentionsControl which mentions trigger notifications.

Migrating an existing message​

Discord requires legacy fields to be explicitly cleared when an edit enables Components V2. Supply at least one reset value in the options object; v2Message() returns a typed MessageV2MigrationReplyOptions payload:

await message.edit(v2Message(
{ content: null, embeds: [], stickers: [] },
"Replacement Components V2 content",
));

Non-empty legacy fields are rejected by the runtime validator. Raw REST payloads may also use an empty sticker_ids array.

Allowed top-level children​

v2Message() accepts these component types at the top level. container() accepts the same list except another container:

TypeDescription
string / text()Text display block. A plain string is always equivalent to text().
container()Styled group of components
section()Text + accessory side by side
separator()Vertical spacing / divider
mediaGallery()Image grid
file()Uploaded file display
actionRow(button1, button2, ...)Row of 1–5 buttons
actionRow(selectMenu)Row containing exactly one string, user, role, mentionable, or channel select
Existing ActionRowData / ActionRowBuilderA complete Discord.js action row, including rows returned by Arcscord's select helpers

text(content, options?)​

Renders a block of Markdown text. A plain string is accepted everywhere text() is — they are fully equivalent.

text("## Title\nBody paragraph with **bold** and `code`.")
"## Title\nBody paragraph with **bold** and `code`." // same result
OptionTypeRequiredDescription
contentstringYesMarkdown content. Supports Discord's Markdown subset.
idnumberNoInternal component ID. Only needed when targeting a specific component in a later edit.

separator(options?)​

Adds vertical space between components. Can optionally render a visible horizontal line.

separator({ divider: true, spacing: "large" })
separator({ spacing: "small" })
separator() // default: no divider, small spacing
OptionTypeDefaultDescription
dividerbooleanfalseRender a visible horizontal rule.
spacing"small" | "large""small"Vertical gap size.
idnumber—Internal component ID.

section(options?, ...content, accessory)​

Places text content on the left and one accessory (a thumbnail or a button) on the right.

import { section, accessory, thumbnail } from "arcscord";

section(
"Text content goes here.",
"A second paragraph.",
accessory(thumbnail({ media: { url: "https://example.com/img.png" } })),
)

With explicit options:

section(
{ id: 1 },
"Support ticket",
"Click the button to open a ticket.",
accessory(openButton.build()),
)

Section options (first argument, optional)​

OptionTypeDescription
idnumberInternal component ID.

Content arguments​

Pass one to three text items before the accessory — strings are automatically wrapped in text().

Accessory (last argument, required)​

Must be accessory(thumbnail(...)) or accessory(button(...)). Only one accessory per section.


accessory(component)​

Wraps a thumbnail or button to mark it as a section accessory.

accessory(thumbnail({ media: { url: "..." }, description: "Alt text" }))
accessory(myButton.build())
Accepted valueDescription
thumbnail(...)An image displayed to the right of the section text.
button(...)An interactive button displayed to the right.

thumbnail(options)​

An image component used exclusively as a section accessory.

thumbnail({
media: { url: "https://cdn.discordapp.com/embed/avatars/0.png" },
description: "Default avatar",
spoiler: false,
})
OptionTypeRequiredDescription
media{ url: string }YesURL of the image. Can be an attachment:// URL or an external URL.
descriptionstringNoAlt text for accessibility. Max 1024 chars.
spoilerbooleanNoBlurs the image until clicked. Default: false.
idnumberNoInternal component ID.

container(options?, ...children)​

Groups components into a visual box with an optional coloured left border.

import { container } from "arcscord";

container(
{ accentColor: 0x5865F2 },
text("Inside the container."),
separator({ divider: true }),
actionRow(myButton.build()),
)

Container options (first argument, optional)​

OptionTypeDescription
accentColornumber | nullRGB colour for the left border accent, as a decimal integer (e.g. 0x5865F2). null removes the accent.
spoilerbooleanBlurs all content inside the container until clicked. Default: false.
idnumberInternal component ID.

Allowed children​

TypeNotes
string / text()Rendered as a text display block
section()Text + accessory
separator()Spacing / divider
mediaGallery()Image grid
file()Uploaded file
actionRow()One to five buttons or exactly one select menu

section() and mediaGallery() cannot be nested inside each other inside a container.

container() constructs a new container and does not accept a complete ContainerBuilder as its sole argument. Pass a complete builder directly to v2Message(), or call validateContainer(builder) when canonical container data is needed separately.


mediaGallery(options)​

Displays a grid of images (1–10 items).

import { mediaGallery } from "arcscord";

mediaGallery({
items: [
{ media: { url: "https://cdn.discordapp.com/embed/avatars/0.png" }, description: "Image 1" },
{ media: { url: "https://cdn.discordapp.com/embed/avatars/1.png" }, description: "Image 2", spoiler: true },
],
})
OptionTypeRequiredDescription
itemsMediaGalleryItem[]Yes1 to 10 images.
idnumberNoInternal component ID.

Each MediaGalleryItem:

FieldTypeRequiredDescription
media{ url: string }YesImage URL. External URLs or attachment:// references.
descriptionstringNoAlt text. Max 1024 chars.
spoilerbooleanNoBlurs the image. Default: false.

file(options)​

Renders a file that was uploaded with the message.

import { file } from "arcscord";
import { Buffer } from "node:buffer";

ctx.reply(v2Message(
{ files: [{ attachment: Buffer.from("hello\n"), name: "output.txt" }] },
file({ file: { url: "attachment://output.txt" } }),
))
OptionTypeRequiredDescription
file{ url: string }YesMust be { url: "attachment://filename" } referencing a file uploaded in the message options.
spoilerbooleanNoBlurs the file preview. Default: false.
idnumberNoInternal component ID.

actionRow(...components)​

Creates a Discord message action row. It accepts either 1–5 buttons, or exactly one select menu. The select may be a string, user, role, mentionable, or channel select.

import { actionRow } from "arcscord";

actionRow(confirmButton.build(), cancelButton.build())

Official Discord.js builders, Discord.js component data, and raw discord-api-types objects are accepted:

import { actionRow, v2Message } from "arcscord";
import { StringSelectMenuBuilder } from "discord.js";

ctx.reply(v2Message(
"Pick a value",
actionRow(
new StringSelectMenuBuilder()
.setCustomId("choice")
.addOptions({ label: "A", value: "a" }, { label: "B", value: "b" }),
),
))

Arcscord's stringSelectMenu(), userSelectMenu(), and related helpers already return a complete ActionRowData. Pass those rows directly to v2Message() or container(); use actionRow(selectBuilder) when you have the select component itself.


Nesting rules​

ParentAllowed children
v2Message() / container()string / text(), section(), separator(), mediaGallery(), file(), and message action rows
section()Text strings / text() + exactly one accessory()
accessory()thumbnail() or a single button()
actionRow()1–5 buttons, or exactly one string/user/role/mentionable/channel select

Examples​

Layout — text, section, container, separator​

ctx.reply(
v2Message(
text("## Components v2 — layout\nText, section with thumbnail, container, separator, and action row."),
section(
"Top-level section — text on the left, thumbnail on the right.",
accessory(thumbnail({
media: { url: "https://cdn.discordapp.com/embed/avatars/0.png" },
description: "Default avatar",
})),
),
separator({ divider: true, spacing: "large" }),
container(
{ accentColor: 0x5865F2 },
"Container content groups text, sections, separators, and action rows.",
section(
"Section inside a container.",
accessory(thumbnail({
media: { url: "https://cdn.discordapp.com/embed/avatars/0.png" },
description: "Avatar",
})),
),
separator({ divider: true, spacing: "large" }),
actionRow(simpleButton.build()),
),
),
)
v2 layout: text, section with thumbnail, container with button

Section — thumbnail and button as accessory​

ctx.reply(
v2Message(
text("## Section with thumbnail accessory"),
section(
{ id: 1 },
"The section component places text on the left and an accessory on the right. The accessory can be a thumbnail (image) or a button.",
accessory(thumbnail({
media: { url: "https://cdn.discordapp.com/embed/avatars/0.png" },
description: "Thumbnail accessory",
})),
),
separator({ divider: false, spacing: "large" }),
section(
{ id: 2 },
"A button can also be an accessory — it appears inline next to the text.",
accessory(simpleButton.build()),
),
),
)
v2 section with thumbnail and button as accessories
ctx.reply(v2Message(
{
files: [{
attachment: Buffer.from("This file is rendered by a Components v2 File component.\n"),
name: "doc-note.txt",
}],
},
text("## Components v2 — media\nMedia gallery and file component with an uploaded attachment."),
mediaGallery({
items: [
{ media: { url: "https://cdn.discordapp.com/embed/avatars/1.png" }, description: "Gallery image 1" },
{ media: { url: "https://cdn.discordapp.com/embed/avatars/2.png" }, description: "Gallery image 2 (spoiler)", spoiler: true },
],
}),
file({ file: { url: "attachment://doc-note.txt" } }),
))
v2 media gallery with two images and a file component

Practical — support panel​

ctx.reply(
v2Message(
container(
section(
"Support",
"Click to open a support ticket.",
accessory(openTicketButton.build()),
),
separator({ spacing: "large" }),
section(
"Bug report",
"Report a bug to the team.",
accessory(bugButton.build()),
),
),
),
)
v2 support panel with two sections and button accessories

Full example​

import { Buffer } from "node:buffer";
import {
accessory, actionRow, container, file,
mediaGallery, section, separator, text, thumbnail, v2Message,
} from "arcscord";

ctx.reply(v2Message(
{
files: [{ attachment: Buffer.from("Report data\n"), name: "report.txt" }],
},
text("## Weekly report"),
section(
"Stats for this week are ready.",
accessory(thumbnail({
media: { url: "https://cdn.discordapp.com/embed/avatars/0.png" },
description: "Report icon",
})),
),
separator({ divider: true, spacing: "large" }),
container(
{ accentColor: 0x57F287 },
text("**Charts**"),
mediaGallery({
items: [
{ media: { url: "https://cdn.discordapp.com/embed/avatars/1.png" }, description: "Chart A" },
{ media: { url: "https://cdn.discordapp.com/embed/avatars/2.png" }, description: "Chart B" },
],
}),
separator({ divider: false, spacing: "small" }),
text("**Raw data**"),
file({ file: { url: "attachment://report.txt" } }),
separator({ divider: true }),
actionRow(downloadButton.build()),
),
))