Components
Buttons, select menus and modals in spearkit follow one pattern: define the appearance, the custom-id pattern, and the handler in one place; register it; then build() the…
import { button, row } from "spearkit";
const vote = button({
id: "vote:{choice}",
label: "Yes",
style: "Success",
run: (ctx) => ctx.update(`You chose ${ctx.params.choice}`), // ctx.params.choice: string
});
client.register(vote); // or client.components.add(vote)
await channel.send({
content: "Cast your vote:",
components: [row(vote.build({ choice: "yes" }))], // build() requires { choice }
});Custom-id patterns
The id is a pattern with the grammar name or name:{param} or
name:{a}:{b}. The leading name is the routing namespace; each {param}
becomes a positional value carried in the custom-id.
- In the handler, params are available as a typed object:
ctx.params.choice. build(params)requires exactly those params and encodes them into the custom-id.
const page = button({
id: "page:{id}:{dir}",
label: "Next",
run: (ctx) => ctx.update(`item ${ctx.params.id}, going ${ctx.params.dir}`),
});
page.build({ id: "42", dir: "next" }); // custom-id "page:42:next"spearkit percent-escapes param values, so they may safely contain :. Custom-ids
are limited to 100 characters (MAX_CUSTOM_ID_LENGTH); build() throws if you
exceed it. For larger state, store the payload with createPayloadStore
and put only the token in {param}.
For advanced use, the codec is exported directly: compilePattern,
buildCustomId, parseCustomId, and paramsFromValues.
Buttons
import { button, linkButton, ButtonStyle } from "spearkit";
const confirm = button({
id: "confirm:{action}",
label: "Confirm",
style: ButtonStyle.Danger, // or the string "Danger"
emoji: "⚠️",
disabled: false,
run: (ctx) => ctx.update(`Confirmed: ${ctx.params.action}`),
});
// Link buttons have no handler and no custom-id:
const docs = linkButton({ url: "https://example.com", label: "Docs" });style accepts the string names "Primary", "Secondary", "Success",
"Danger", or the ButtonStyle enum. It defaults to "Secondary".
All component builders (button, the five selects, and modal) also accept
guards?: readonly Guard[] — preconditions evaluated before the handler runs.
See Guards.
The ButtonContext adds, on top of the shared reply helpers:
| Member | Description |
|---|---|
ctx.params | Decoded custom-id params. |
ctx.update(input) | Edit the message the button is on. |
ctx.deferUpdate() | Acknowledge without editing yet. |
ctx.showModal(modal) | Open a modal in response. |
ctx.message | The message the button belongs to. |
ctx.customId | The raw custom-id. |
Select menus
There are five select builders. All share placeholder, minValues,
maxValues, and disabled; the string select additionally takes options,
and the channel select takes channelTypes.
import { stringSelect, channelSelect, ChannelType } from "spearkit";
const colour = stringSelect({
id: "colour",
placeholder: "Pick a colour",
minValues: 1,
maxValues: 1,
options: [
{ label: "Red", value: "red" },
{ label: "Green", value: "green", description: "the calm one" },
{ label: "Blue", value: "blue", default: true },
],
run: (ctx) => ctx.reply({ content: `You picked ${ctx.values.join(", ")}`, ephemeral: true }),
});
const pickChannel = channelSelect({
id: "pick-channel",
channelTypes: [ChannelType.GuildText],
run: (ctx) => ctx.reply({ content: `${ctx.values.length} channel(s)`, ephemeral: true }),
});Each select context exposes the relevant resolved data:
| Builder | Context | Extra accessors |
|---|---|---|
stringSelect | StringSelectContext | values: string[], value: string | undefined |
userSelect | UserSelectContext | values, users, members |
roleSelect | RoleSelectContext | values, roles |
channelSelect | ChannelSelectContext | values, channels |
mentionableSelect | MentionableSelectContext | values, users, roles, members |
Select contexts also have ctx.params, ctx.update, ctx.deferUpdate,
ctx.showModal, and the shared reply helpers.
Modals
A modal declares its fields as a map of name → field definition. The submit
handler receives the submitted values in ctx.fields, keyed (and typed) by
those names, plus any custom-id params in ctx.params.
Every field renders as a Discord Label component (type 18) — the recommended modal surface. Legacy Action Row + Text Input modals are no longer emitted; the visible UX is identical, but new clients receive Label payloads.
import { modal, textInput } from "spearkit";
const feedback = modal({
id: "feedback:{ticket}",
title: "Feedback",
fields: {
summary: textInput({ label: "Summary", required: true }),
detail: textInput({ label: "Details", style: "Paragraph", maxLength: 2000 }),
},
run: (ctx) =>
ctx.reply({
// ctx.params.ticket: string, ctx.fields.summary / ctx.fields.detail: string
content: `#${ctx.params.ticket}: ${ctx.fields.summary}`,
ephemeral: true,
}),
});textInput config: label (required), description?, style ("Short"
default, or "Paragraph", or a TextInputStyle), placeholder, required,
minLength, maxLength, value.
Field types
Beyond text inputs, modals support the full Label surface — each definition carries its handler value type:
| Builder | Submits as | Notes |
|---|---|---|
textInput(...) | string | Classic text input inside a Label. |
radioGroup({ options }) | literal union of option values | Exactly one pick; required: false widens to | undefined. |
checkboxGroup({ options }) | array of option values | minValues: 0 makes the group skippable ([]). |
checkbox({ label }) | boolean | Cannot be required per the Discord spec. |
fileUpload({...}) | Attachment[] | allowedFileTypes filters MIME types / extensions. |
stringSelectField({ options }) | string[] | Select menus inside a modal. |
userSelectField() / roleSelectField() / channelSelectField() / mentionableSelectField() | string[] of ids | Entity selects inside a modal. |
import {
checkbox,
checkboxGroup,
fileUpload,
modal,
radioGroup,
textInput,
} from "spearkit";
const report = modal({
id: "report:{userId}",
title: "Report",
fields: {
reason: textInput({ label: "Why", style: "Paragraph", required: true }),
kind: radioGroup({
label: "Type",
description: "What is this?",
options: [
{ label: "Spam", value: "spam" },
{ label: "Abuse", value: "abuse" },
],
}),
extras: checkboxGroup({
label: "Also",
minValues: 0,
maxValues: 2,
options: [
{ label: "Ban", value: "ban" },
{ label: "Delete", value: "delete" },
],
}),
agree: checkbox({ label: "I understand" }),
proof: fileUpload({ label: "Screenshots", minValues: 0, maxValues: 5 }),
},
run: (ctx) => {
ctx.params.userId; // string
ctx.fields.reason; // string
ctx.fields.kind; // "spam" | "abuse"
ctx.fields.extras; // ("ban" | "delete")[]
ctx.fields.agree; // boolean
ctx.fields.proof; // Attachment[]
},
});Discord rules worth knowing: radio groups need 2–10 options, checkbox
groups allow at most one checkbox per option and maxValues ≤ options.length,
and a group/upload with required cannot have minValues: 0 — spearkit
derives the payload flag from minValues for you.
Open a modal from a command or a component handler with showModal — modals
cannot be the response to another modal, but they can follow a command or a
button/select:
import { command } from "spearkit";
const ask = command({
name: "ask",
description: "Open the feedback form",
run: (ctx) => ctx.showModal(feedback.build({ ticket: "1234" })),
});Action rows
row(...components) wraps builders in an ActionRowBuilder. A row holds up to
five buttons, or exactly one select menu.
import { row } from "spearkit";
const components = [
row(confirm.build({ action: "delete" }), docs),
row(colour.build()),
];
await channel.send({ content: "Choose:", components });Components V2 (message layout)
Components V2 is Discord's newer message surface: instead of content +
embeds you compose the whole message from layout parts — text displays,
separators, sections, media galleries, files and accent-coloured containers.
The flag disables content, embeds, poll and stickers for that message.
spearkit ships thin wrappers over the discord.js builders — no new routing,
interactive children stay ordinary .build() rows:
import {
MessageFlags,
button,
container,
row,
section,
separator,
textDisplay,
} from "spearkit";
const skip = button({
id: "player-skip",
label: "Skip",
run: (ctx) => ctx.update("Skipped."),
});
await ctx.reply({
// flags: MessageFlags.IsComponentsV2, <- optional; spearkit ORs it for you
components: [
textDisplay("## Queue"),
container({
accentColor: 0x5865f2,
children: [
section({
children: ["**Now playing** — Song", "by Artist"],
thumbnail: { url: "https://cdn.example/art.png" },
}),
separator(),
row(skip.build()),
],
}),
],
});Helpers: textDisplay(content), separator({ spacing?, divider? }),
section({ children, button? | thumbnail? }) (children accept plain strings),
mediaGallery([{ url, description?, spoiler? }]), file(url, { spoiler? }),
thumbnail({ url, description?, spoiler? }) and
container({ accentColor?, spoiler?, children }). Containers cannot be
nested. When spearkit sees a V2 tree in components it sets
MessageFlags.IsComponentsV2 for you — and throws if content, embeds,
poll or stickers ride along.
Classic action-row messages keep working unchanged; embeds are not going away.
paginate/confirm still render legacy rows and will migrate separately.
Registering and routing
Register components like anything else:
client.register(vote, colour, feedback);
// equivalently:
client.components.add(vote, colour, feedback);SpearClient routes every button, select and modal interaction to the matching
namespace automatically. The ComponentRegistry API:
| Member | Description |
|---|---|
add(...defs) | Register components (override by namespace). |
size | Number registered. |
onError(handler) | Set the error handler. |
handle(interaction) | Route an interaction; returns true if matched. |
setDefaultGuards(guards) | Guards run before each component's own guards. |
setLogger and setUsageHook also exist; the client wires all three for you.
Error handling
By default a throwing handler emits the client error event and replies with an
ephemeral message. Customise it:
client.components.onError((error, interaction) => {
console.error("component failed", error);
});End-to-end example
import {
SpearClient,
Intents,
command,
button,
stringSelect,
modal,
textInput,
row,
} from "spearkit";
const client = new SpearClient({ intents: Intents.default });
const open = button({
id: "open-form:{topic}",
label: "Open form",
style: "Primary",
run: (ctx) => ctx.showModal(form.build({ topic: ctx.params.topic })),
});
const rating = stringSelect({
id: "rating",
placeholder: "Rate us",
options: [
{ label: "Good", value: "good" },
{ label: "Bad", value: "bad" },
],
run: (ctx) => ctx.reply({ content: `Thanks: ${ctx.value}`, ephemeral: true }),
});
const form = modal({
id: "form:{topic}",
title: "Tell us more",
fields: { body: textInput({ label: "Message", style: "Paragraph", required: true }) },
run: (ctx) => ctx.reply({ content: `[${ctx.params.topic}] ${ctx.fields.body}`, ephemeral: true }),
});
const panel = command({
name: "panel",
description: "Show the panel",
run: (ctx) =>
ctx.reply({
content: "How was it?",
components: [row(open.build({ topic: "support" })), row(rating.build())],
}),
});
client.register(panel, open, rating, form);See also
Options
Slash command options are declared as a map of name → builder. spearkit infers the exact value type each option resolves to, so your handler's ctx.options is fully typed — no…
Context-menu commands
Context-menu commands are the right-click "Apps" actions Discord shows on a user or a message. spearkit makes them first-class: define one with userCommand or messageCommand…