spearkit
Guides

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:

MemberDescription
ctx.paramsDecoded 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.messageThe message the button belongs to.
ctx.customIdThe 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:

BuilderContextExtra accessors
stringSelectStringSelectContextvalues: string[], value: string | undefined
userSelectUserSelectContextvalues, users, members
roleSelectRoleSelectContextvalues, roles
channelSelectChannelSelectContextvalues, channels
mentionableSelectMentionableSelectContextvalues, 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:

BuilderSubmits asNotes
textInput(...)stringClassic text input inside a Label.
radioGroup({ options })literal union of option valuesExactly one pick; required: false widens to | undefined.
checkboxGroup({ options })array of option valuesminValues: 0 makes the group skippable ([]).
checkbox({ label })booleanCannot 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 idsEntity 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:

MemberDescription
add(...defs)Register components (override by namespace).
sizeNumber 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

  • Commands — opening components from commands.
  • Contexts — the reply/update helpers contexts share.
  • Client — registration and routing.

On this page