Only show guides for your app
A /poll command with button voting and a live bar-chart embed that updates on every click — one vote per person, and an honest note about why active polls reset on restart.
This recipe builds a poll bot in discord.js: one /poll command with a question and options, buttons to vote, and an embed that redraws a little bar chart every time someone clicks. Buttons (rather than reactions) give you exact control — one vote per person, and people can change their mind.
| At a glance | |
|---|---|
| You need | A working bot from Host a discord.js bot |
| Plan | Free or premium — no premium features required |
| Time | About twenty minutes |
New here? Start with Host a discord.js bot.
You could build a poll with emoji reactions, but buttons win for real voting:
| Buttons | Reactions | |
|---|---|---|
| One vote per person | Yes — you control it in code | No — people can add every emoji |
| Change a vote | Yes — clicking a new option replaces the old | Clumsy — remove one, add another |
| Live tallies | Yes — edit the embed on each click | Manual — count reactions yourself |
| Extra intents | None | Needs the reactions intent + partials |
This recipe uses buttons.
Here's the complete index.js. Options are passed as one string separated by |.
require('dotenv').config();
const {
Client, GatewayIntentBits, Events,
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, SlashCommandBuilder,
} = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// messageId -> { question, options, votes: Map<userId, optionIndex> }
// In memory: a restart wipes every active poll. See bot-data-storage to persist.
const polls = new Map();
function render(poll) {
const counts = poll.options.map(() => 0);
for (const choice of poll.votes.values()) counts[choice]++;
const total = poll.votes.size || 1;
const lines = poll.options.map((opt, i) => {
const pct = Math.round((counts[i] / total) * 100);
const filled = Math.round(pct / 10);
return `**${opt}**\n${'█'.repeat(filled)}${'░'.repeat(10 - filled)} ${counts[i]} (${pct}%)`;
});
return new EmbedBuilder().setTitle(`📊 ${poll.question}`).setDescription(lines.join('\n\n')).setColor(0x5865f2);
}
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([
new SlashCommandBuilder()
.setName('poll')
.setDescription('Start a poll (up to 5 options)')
.addStringOption((o) => o.setName('question').setDescription('The question').setRequired(true))
.addStringOption((o) => o.setName('options').setDescription('Options separated by |').setRequired(true))
.toJSON(),
]);
console.log(`Listening as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (interaction.isChatInputCommand() && interaction.commandName === 'poll') {
const question = interaction.options.getString('question');
const options = interaction.options.getString('options').split('|').map((s) => s.trim()).filter(Boolean).slice(0, 5);
if (options.length < 2) {
await interaction.reply({ content: 'Give at least two options, separated by `|`.', ephemeral: true });
return;
}
const poll = { question, options, votes: new Map() };
const row = new ActionRowBuilder().addComponents(
options.map((opt, i) =>
new ButtonBuilder().setCustomId(`poll:${i}`).setLabel(opt.slice(0, 80)).setStyle(ButtonStyle.Secondary)),
);
const message = await interaction.reply({ embeds: [render(poll)], components: [row], fetchReply: true });
polls.set(message.id, poll);
return;
}
if (interaction.isButton() && interaction.customId.startsWith('poll:')) {
const poll = polls.get(interaction.message.id);
if (!poll) {
await interaction.reply({ content: 'This poll is no longer active.', ephemeral: true });
return;
}
poll.votes.set(interaction.user.id, Number(interaction.customId.split(':')[1]));
await interaction.update({ embeds: [render(poll)] });
}
});
client.login(process.env.DISCORD_TOKEN);
Restart, then run /poll question:"Pizza toppings?" options:"Pepperoni | Mushroom | Pineapple".
Map keyed by user ID. Setting the same key again overwrites the old choice — so a second click just moves your vote, it doesn't add one. That's why the percentages always add up.render counts the votes and draws a ten-block bar per option. interaction.update edits the original message in place, so everyone watching sees the new numbers instantly — no extra messages..slice(0, 5); button labels cap at 80 characters.🎯 Good to know: Votes are anonymous — the bot stores who voted (to enforce one-per-person) but never shows it. If you want to close a poll, add a
/endpollcommand that removes the buttons withinteraction.message.edit({ components: [] }).
Every poll lives in the polls Map in memory, so a restart ends all active polls — the buttons will answer "This poll is no longer active." For quick polls that's usually fine. To let polls survive restarts, save each poll's votes to SQLite and reload them on boot; Storing data for your bot shows the pattern, and the giveaway bot is a worked example of the same idea.
Start a poll, click an option, and watch the bar and percentage jump. Click a different option and confirm your vote moves rather than adds (the total stays the same). Vote from a second account to see the tallies climb.
| Symptom | Fix |
|---|---|
| "Give at least two options" | You gave fewer than two options — separate every option with the vertical-bar character exactly as the usage example above shows. |
| Only some options appear | Discord allows five buttons per row; extras past five are trimmed. |
| Old polls say "no longer active" | Expected after a restart — the poll state is in memory. Persist it to keep polls alive. |
| "This interaction failed" | An error was thrown before replying — check the console and see Crash-proof your bot. |