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
Requests clients to resize the keyboard vertically for optimal fit
Keeps the keyboard visible even when the user sends a message
Shows the keyboard only to specific users (those mentioned in the message or replying to the bot)
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" );
Callback
URL
Web App
Switch Inline
keyboard . text ( "Label" , "callback_data" )
Sends a callback query to your bot when pressed. keyboard . url ( "Visit Site" , "https://example.com" )
Opens a URL in the user’s browser. keyboard . webApp ( "Open App" , "https://example.com/app" )
Opens a Telegram Web App. keyboard . switchInline ( "Share" , "query" )
Prompts the user to select a chat and inserts an inline query.
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
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