Skip to main content
Keyboards are one of the most powerful features for bot interaction. grammY provides two types: custom keyboards (shown below the message input) and inline keyboards (shown below messages).

Custom Keyboards

Custom keyboards replace the user’s system keyboard:
import { Bot, Keyboard } from "grammy";

const bot = new Bot("YOUR_BOT_TOKEN");

bot.command("start", async (ctx) => {
  const keyboard = new Keyboard()
    .text("✅ Yes").text("❌ No").row()
    .text("🔙 Back");

  await ctx.reply("Do you want to continue?", {
    reply_markup: keyboard,
  });
});

Building Custom Keyboards

const keyboard = new Keyboard()
  // Add buttons in a row
  .text("Button 1").text("Button 2")
  // Start a new row
  .row()
  .text("Button 3")
  // Request user's contact
  .requestContact("Share Contact")
  // Request user's location
  .requestLocation("Share Location")
  // Start new row
  .row()
  // Request user to create a poll
  .requestPoll("Create Poll")
  // Make keyboard persistent
  .persistent()
  // Resize keyboard
  .resized();

Keyboard Options

resized
() => Keyboard
Requests clients to resize the keyboard vertically for optimal fit
persistent
() => Keyboard
Keeps the keyboard visible even when the user sends a message
selective
() => Keyboard
Shows the keyboard only to specific users (those mentioned in the message or replying to the bot)
oneTime
() => Keyboard
Hides the keyboard after the user presses a button
placeholder
(text: string) => Keyboard
Sets the placeholder text shown in the input field

Inline Keyboards

Inline keyboards are attached to messages and trigger callback queries:
import { Bot, InlineKeyboard } from "grammy";

const bot = new Bot("YOUR_BOT_TOKEN");

bot.command("menu", async (ctx) => {
  const keyboard = new InlineKeyboard()
    .text("Option 1", "option-1")
    .text("Option 2", "option-2")
    .row()
    .url("Visit Website", "https://grammy.dev");

  await ctx.reply("Choose an option:", {
    reply_markup: keyboard,
  });
});

// Handle button clicks
bot.callbackQuery("option-1", async (ctx) => {
  await ctx.answerCallbackQuery("You chose Option 1!");
  await ctx.editMessageText("You selected: Option 1");
});

Building Inline Keyboards

const keyboard = new InlineKeyboard()
  // Callback button (sends callback query)
  .text("Click me", "callback-data")
  // URL button (opens a link)
  .url("Open Link", "https://example.com")
  // Start new row
  .row()
  // Web app button
  .webApp("Open Web App", "https://example.com/app")
  // Switch to inline query
  .switchInline("Share")
  // Switch to inline in current chat
  .switchInlineCurrent("Search here")
  // Start new row
  .row()
  // Login URL
  .login("Login", "https://example.com/auth")
  // Pay button
  .pay("Pay $5.00");

Button Types

keyboard.text("Label", "callback_data")
Sends a callback query to your bot when pressed.

Dynamic Keyboards

Generate keyboards based on data:
const items = ["Apple", "Banana", "Cherry", "Date"];

const keyboard = new InlineKeyboard();

items.forEach((item, index) => {
  keyboard.text(item, `item-${index}`);
  // Create a new row every 2 items
  if ((index + 1) % 2 === 0) keyboard.row();
});

await ctx.reply("Choose a fruit:", { reply_markup: keyboard });

Removing Keyboards

Remove a custom keyboard:
import { Bot } from "grammy";

const bot = new Bot("YOUR_BOT_TOKEN");

bot.command("remove", async (ctx) => {
  await ctx.reply("Keyboard removed", {
    reply_markup: { remove_keyboard: true },
  });
});

Handling Callbacks

// Handle specific callback data
bot.callbackQuery("button-1", async (ctx) => {
  await ctx.answerCallbackQuery("Button 1 pressed!");
});

// Handle callback data patterns
bot.callbackQuery(/item-\d+/, async (ctx) => {
  const itemId = ctx.callbackQuery.data.split("-")[1];
  await ctx.answerCallbackQuery(`You selected item ${itemId}`);
});

// Handle all callback queries
bot.on("callback_query:data", async (ctx) => {
  console.log("Callback data:", ctx.callbackQuery.data);
  await ctx.answerCallbackQuery();
});
Always call answerCallbackQuery() within 30 seconds or Telegram will show an error to the user.

Best Practices

Keep Labels Short

Button labels should be concise—Telegram truncates long text.

Use Meaningful Callbacks

Use descriptive callback data to make your code maintainable.

Provide Feedback

Always call answerCallbackQuery() to acknowledge button presses.

Update Messages

Use editMessageText() or editMessageReplyMarkup() to update inline keyboards.

Examples

Pagination

function getPaginationKeyboard(page: number, totalPages: number) {
  const keyboard = new InlineKeyboard();

  if (page > 0) {
    keyboard.text("⬅️ Previous", `page-${page - 1}`);
  }
  if (page < totalPages - 1) {
    keyboard.text("➡️ Next", `page-${page + 1}`);
  }

  return keyboard;
}

bot.callbackQuery(/page-\d+/, async (ctx) => {
  const page = parseInt(ctx.callbackQuery.data.split("-")[1]);
  const keyboard = getPaginationKeyboard(page, 10);
  
  await ctx.editMessageText(`Page ${page + 1}`, {
    reply_markup: keyboard,
  });
  await ctx.answerCallbackQuery();
});

Confirmation Dialog

bot.command("delete", async (ctx) => {
  const keyboard = new InlineKeyboard()
    .text("✅ Confirm", "confirm-delete")
    .text("❌ Cancel", "cancel-delete");

  await ctx.reply("Are you sure you want to delete?", {
    reply_markup: keyboard,
  });
});

bot.callbackQuery("confirm-delete", async (ctx) => {
  await ctx.editMessageText("Deleted!");
  await ctx.answerCallbackQuery();
});

bot.callbackQuery("cancel-delete", async (ctx) => {
  await ctx.editMessageText("Cancelled.");
  await ctx.answerCallbackQuery();
});

See Also

Build docs developers (and LLMs) love