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.
idnumberInternal 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()),
),
))