Skip to content

Components

Build the cards, buttons, and rows your bot sends as classes over the discord.js builders. Covers a row of buttons, which builder each key wraps, your bot color, and emojis by name.

A line of text needs nothing more than this.reply('Banned.'). A card with a heading, a body, and a row of buttons under it needs a discord.js builder. You put that builder in a class.

Subclass BuilderComponent, name the builder you want in the generic and in the super() call, and build the thing in the constructor.

src/components/BanCard.ts
import { BuilderComponent } from '@seedcord/gateway';

export class BanCard extends BuilderComponent<'container'> {
    constructor(name: string, reason: string) {
        super('container');

        this.instance.addTextDisplayComponents(
            (text) => text.setContent(`## Banned ${name}`),
            (text) => text.setContent(reason)
        );
    }
}

'container' appears twice up there, once in the generic and once in the super() call. The super() argument picks which builder gets constructed. The generic types this.instance as that builder. A ContainerBuilder declares no setTitle, so calling one stops the build.

Write the two the same. The compiler rejects a mismatch.

name and reason arrive through the constructor, so one BanCard covers every ban card your bot sends. Your ban handler passes its own values. The next handler that needs one imports the same file.

To reword the card, you change one line in BanCard.ts. Every handler that sends one gets the new wording.

this.instance stays protected, so your handler reads .component to get the builder out.

src/handlers/Ban.ts
@SlashRoute('ban')
export class Ban extends SlashHandler<'ban'> {
    public async execute(): Promise<void> {
        const target = this.options.getUser('target');
        const reason = this.options.getString('reason');

        const card = new BanCard(target.username, reason ?? 'none');
        await this.reply({ components: [card.component] });
    }
}

card.component there is the live builder, so a setter you call on it does change the message. Nothing stops you.

Make that call in the constructor anyway. this.instance is protected to keep a ban card's whole description in one file. A call from your handler splits that description across two. Then, if you go to BanCard.ts, you no longer see the complete card.

Reading .component also applies your bot color. The color resolves on that read. Resolving it in the constructor would give the default color to every component built before your config loads.

Tip

seedcord imports your commands, handlers, events, and subscribers from the paths you set in your bot config. Your own code imports a component directly, so your config never mentions that folder. Make one anyway, at src/components, and think in building blocks.

A card is a building block, and a row of buttons is another. A modal is a building block, and a row of inputs is another, maybe you have a menu builder that dynamically creates its values, or you want to create items in one conditionally. Each one is a BuilderComponent subclass in that folder. Your handlers import the ones they send.

For example, you put a BanCard and a BanActions row of buttons in that folder, and your Ban handler imports both.

my-bot
src/
├─ commands/
├─ components/
│  ├─ BanCard.ts
│  └─ BanActions.ts
├─ handlers/
└─ bot.ts

A row of buttons

ActionRowBuilder is generic over what goes inside it, so a row you build by hand writes the child builder into the type every time. RowComponent takes a key and does that for you, typing this.instance to whatever the row holds. A row holds buttons or one select menu.

src/components/BanActions.ts
export const AppealId = new CustomId('appeal').snowflake('userId');

export class BanActions extends RowComponent<'button'> {
    constructor(userId: string) {
        super('button');

        this.instance.addComponents(
            new ButtonBuilder()
                .setCustomId(AppealId.encode({ userId }))
                .setLabel('Appeal')
                .setStyle(ButtonStyle.Secondary)
        );
    }
}

AppealId sits outside the class, exported. The button encodes a userId into it. Discord hands that same string back when someone clicks, and your handler imports AppealId to decode it.

Keep that declaration in the file with the component that encodes it. Both sides read the field names off one CustomId, so a rename cannot reach one and miss the other.

addActionRowComponents puts a row inside a container, which is how a card and its buttons reach Discord as one message.

Warning

Import every builder from @discordjs/builders. discord.js re-exports its own CJS copy of each one, so the two copies are always different classes. Nesting one inside the other is where that shows, because instanceof returns false and toJSON misbehaves.

The @seedcord/no-djs-builder-import lint rule flags every runtime import of a builder from discord.js. A type-only import is erased before runtime so that one is safe.

Which builder each key wraps

Both the generic and the super() call take one of these keys.

what you are buildingkeys
a message, ComponentsV2container, text_display, section, separator, media, file
an embedembed
a command definitioncommand, context_menu, subcommand, group
a modalmodal, label, text_input, file_upload, checkbox, checkbox_group, checkbox_group_option, radio_group, radio_group_option
a row childbutton, menu_string, menu_user, menu_role, menu_channel, menu_mentionable
one option inside a string menumenu_option_string

BuilderComponent takes every key in that table. The six row-child keys also work with RowComponent, which wraps each one in an ActionRowBuilder. So RowComponent<'button'> types this.instance as a row of buttons. BuilderComponent<'button'> types it as the bare ButtonBuilder you would add to one.

Your bot color

Every embed and container your bot sends takes a color. Setting it on each builder leaves every new component one more place to forget it.

botColor takes one value for the whole bot.

src/bot.ts
import { resolve } from 'node:path';

import { Seedcord } from '@seedcord/gateway';
import { GatewayIntentBits } from 'discord.js';

export const seedcord = new Seedcord({
    bot,
    subscribers: { path: null },
    botColor: '#fe565a'
});

Reading .component applies it. An embed takes it as its color, and a container takes it as its accent. Every other builder gets nothing, since nothing else carries one.

Set your own in the constructor, with setColor on an embed or setAccentColor on a container. The check only fills an empty color, so yours survives.

src/components/MaintenanceEmbed.ts
export class MaintenanceEmbed extends BuilderComponent<'embed'> {
    constructor(until: string) {
        super('embed');

        this.instance
            .setTitle('Maintenance')
            .setDescription(`Back at ${until}.`);
    }
}

Discord forbids an embed on a ComponentsV2 message, which is what seedcord's reply methods send. An embed reaches Discord through a channel send or a raw ack.

botColor accepts a #hex string, a discord.js color name like 'Blurple', a raw integer, or an [r, g, b] tuple. Leave the key out for Discord's default.

Emojis by name

You've gone hunting for an emoji id before. Copy <:streak_flame:1872389747982323426> out of the client, paste it into a constants file, then do the next one. From then on you keep that file in sync by hand.

A re-upload gives the emoji a new id, so every copy of the old one points at nothing. A rename leaves the name in your code out of step with the one in Discord. Some emojis come from your app and some from one guild, so each of those is its own lookup.

seedcord does those lookups for you, once, while the bot starts. Name each custom emoji in your config, then read it back by that name.

src/bot.ts
export const seedcord = new Seedcord({
    bot: {
        ...shared,
        events: { path: null },
        emojis: {
            Confirm: 'confirmation_check',
            Cancel: 'confirmation_cross',
            // or look up an emoji in a guild your bot is in
            Streak: ['streak_flame', '1872389747982323426']
        }
    },
    subscribers: { path: null }
});

A plain string is the name of an application emoji, one you upload to your app in the developer portal. Run seedcord codegen after you change that block. It writes the key names into EmojiMap, which types the Emojis accessor.

Your editor then offers those keys the moment you type the dot.

import { Emojis } from '@seedcord/gateway';

Emojis.
  • Cancel
  • Confirm
  • Streak
src/components/StreakCard.tshover for typestap for types, arrow keys walk the tokens
export class class StreakCardStreakCard extends class BuilderComponent<BuilderKey extends BuilderType>BuilderComponent<'container'> {
    constructor(days: numberdays: number) {
        super('container');

        this.BaseComponent<ContainerBuilder>.instance: ContainerBuilderinstance.ContainerBuilder.addTextDisplayComponents(...components: RestOrArray<APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)>): ContainerBuilderaddTextDisplayComponents((text: TextDisplayBuildertext) =>
            text: TextDisplayBuildertext.TextDisplayBuilder.setContent(content: string): TextDisplayBuildersetContent(`${const Emojis: InjectedEmojiMapEmojis.type Streak: GatewayEmoji<GuildEmoji>Streak} ${days: numberdays} days`)
        );
    }
}

A resolved emoji stringifies to <:name:id>, or <a:name:id> when it animates. It also passes straight to any builder's setEmoji.

hover for typestap for types, arrow keys walk the tokens
this.BaseComponent<ActionRowBuilder<ButtonBuilder>>.instance: ActionRowBuilder<ButtonBuilder>instance.ActionRowBuilder<ButtonBuilder>.addComponents(...components: RestOrArray<ButtonBuilder>): ActionRowBuilder<ButtonBuilder>addComponents(
    new new ButtonBuilder(data?: Partial<APIButtonComponent>): ButtonBuilderButtonBuilder()
        .ButtonBuilder.setCustomId(customId: string): ButtonBuildersetCustomId(confirmId: stringconfirmId)
        .ButtonBuilder.setEmoji(emoji: APIMessageComponentEmoji): ButtonBuildersetEmoji(const Emojis: InjectedEmojiMapEmojis.type Confirm: GatewayEmoji<ApplicationEmoji>Confirm)
        .ButtonBuilder.setLabel(label: string): ButtonBuildersetLabel('Confirm')
        .ButtonBuilder.setStyle(style: ButtonStyle): ButtonBuildersetStyle(enum ButtonStyleButtonStyle.function (enum member) ButtonStyle.Success = 3Success)
);

seedcord stops startup when a name won't resolve, and the error carries every failure at once. Emojis throws when you read a key before startup fills it.

Gateway only

A [name, guildId] emoji needs the Guilds intent, since seedcord reads that guild's emoji list off the discord.js client cache. Without it startup reports the guild as unavailable. On http the same tuple resolves over one REST call to that guild's emoji route.

Gateway and http differ

On gateway, each one is a GatewayEmoji. In the example above, Emojis.Streak carries a .source holding the live discord.js GuildEmoji or ApplicationEmoji, typed per key from the tag codegen wrote. On http it is a plain ResolvedEmoji with the name, the id, and the animated flag. Both stringify and both pass to setEmoji.