ux patterns
product decisions, not api surface: what makes a telegram bot feel like an app instead of a chatbot, and the yaebal pieces that make each pattern a few lines.
the one rule
people tap buttons; they don't type commands. commands are for discovery — the / menu, deep links. navigation belongs to inline buttons, and a screen change edits the current
message instead of sending a new one. every pattern below follows from that.
import { InlineKeyboard, blockquote, bold, callbackData, createBot, format, italic } from "yaebal";
const nav = callbackData("nav", { to: String });
const toggle = callbackData("toggle", { key: String });
const prefs = { alerts: true, digest: false };
const label = (on: boolean, text: string) => `${on ? "✅" : "⬜"} ${text}`;
const screens = {
home: () => ({
text: format`${bold("🤖 demo bot")}
${blockquote("every screen is this one message, edited in place. tap a tile.")}`,
markup: new InlineKeyboard().text("⚙️ settings", nav.pack({ to: "settings" })),
}),
settings: () => ({
text: format`${bold("⚙️ settings")} ${italic("· home › settings")}
${blockquote("tap a row to flip it — the label is the state.")}`,
markup: new InlineKeyboard()
.text(label(prefs.alerts, "alerts"), toggle.pack({ key: "alerts" }))
.row()
.text(label(prefs.digest, "daily digest"), toggle.pack({ key: "digest" }))
.row()
.text("◀ back", nav.pack({ to: "home" })),
}),
};
const bot = createBot(process.env.BOT_TOKEN!);
// ctx.show sends on a command and edits the pressed button's message on a tap
bot.command("start", (ctx) => {
const screen = screens.home();
return ctx.show(screen.text, { reply_markup: screen.markup });
});
bot.callbackQuery(nav, async (ctx) => {
await ctx.answer(); // stop the spinner — the edited message is the feedback
const screen = ctx.queryData.to === "settings" ? screens.settings() : screens.home();
await ctx.show(screen.text, { reply_markup: screen.markup });
});
bot.callbackQuery(toggle, async (ctx) => {
const key = ctx.queryData.key as keyof typeof prefs;
prefs[key] = !prefs[key];
await ctx.answer(prefs[key] ? "on" : "off");
const screen = screens.settings();
await ctx.show(screen.text, { reply_markup: screen.markup });
});
bot.start();a /start that works
a bold one-line title, a two-or-three line pitch in a blockquote, an optional italic meta line,
then the primary actions as tiles — at most four rows before a phone scrolls. no wall of text, no
"welcome!" line, no list of every feature: /start is a homepage, not a sitemap.
import { InlineKeyboard, blockquote, bold, format, italic } from "yaebal";
const hero = format`${bold("🤖 orders bot")}
${blockquote("track an order, reorder in two taps, get notified when it ships.")}
${italic("v2.1 · beta")}`;
bot.command("start", (ctx) =>
ctx.reply(hero, {
reply_markup: new InlineKeyboard()
.text("📦 my orders", nav.pack({ to: "orders" }))
.text("🔁 reorder", nav.pack({ to: "reorder" }))
.row()
.text("❓ help", nav.pack({ to: "help" })),
}),
);edit in place
navigation edits; events send. moving between screens edits the message the button belongs
to, so the chat stays clean and "where am i" is always the bottom of the chat. ctx.show(text, extra) does exactly that: on a button press it edits that message,
anywhere else it sends — one screen function serves /settings and the tap that
redraws it, and an unchanged screen or an uneditable media message is handled for you. send a new message for things worth keeping in history — a receipt, a shipped
order, an error the user must see later. @yaebal/morda turns
this into declared windows with a back stack, if your menus outgrow a few handlers.
back and home
- a breadcrumb in the text —
⚙️ settings · home › settings. - a
◀ backbutton in the last row of every non-home screen — never a dead end. - a
🏠 homebutton deeper than two levels, so nobody taps back three times. - one typed payload for navigation —
callbackData("nav", { to: String })— and one handler for it.
toggles: the label is the state
✅ alerts / ⬜ alerts: one tap flips the value and re-renders the same
screen, and the changed label is the feedback. one handler per payload schema
(toggle), not per field. keep toggles and destination tiles on separate rows — a
user can't tell "flips" from "navigates" when they share one.
destructive actions confirm
"delete account" never fires on a single tap. the red tile edits the screen into a
confirmation that restates the impact, with the safe action first. don't confirm with show_alert — alerts block the client, cap at 200 characters and can't hold
buttons.
const account = callbackData("account", { step: field.enum(["ask", "delete"]) });
// settings screen: the destructive tile is red and only *asks*
new InlineKeyboard().text("🗑 delete account", account.pack({ step: "ask" }), "danger");
bot.callbackQuery(account, async (ctx) => {
await ctx.answer();
if (ctx.queryData.step === "ask") {
return ctx.editText("delete your account? all data is erased — this can't be undone.", {
reply_markup: new InlineKeyboard()
.text("❌ cancel", nav.pack({ to: "settings" })) // safe default first
.text("💥 yes, delete", account.pack({ step: "delete" }), "danger"),
});
}
await deleteAccount(ctx.from.id);
await ctx.editText("account deleted.");
});stop every spinner
telegram shows a spinner on a pressed button until the bot answers the callback query — forget
it and the button looks broken for ~15 seconds. answer first: empty for navigation (the edit is
the feedback), a short toast (ctx.answer("saved")) for small confirmations, show_alert only for things that must be acknowledged. @yaebal/auto-answer answers for you when a handler
doesn't, so a forgotten call can't ship.
show progress
anything slower than ~300ms gets an instant placeholder. edits are limited to about one per
second per message, so report progress coarsely (every couple of seconds), not per item. for llm
answers, ctx.ai.replyStream streams natively.
bot.callbackQuery(search, async (ctx) => {
await ctx.answer();
await ctx.editText("⏳ searching…"); // instant feedback
const results = await runSearch(ctx.queryData.q); // may take seconds
await ctx.editText(renderResults(results), { reply_markup: resultsKeyboard(results) });
});empty states
a blank list reads as a bug. say what will appear there and offer the action that creates the first item; for a filtered-to-empty list, offer to clear the filter. @yaebal/pagination handles the long-list case.
const text = orders.length === 0
? format`${bold("📦 my orders")}
${blockquote("no orders yet — the first one shows up here the moment you place it.")}`
: renderOrders(orders);
const markup = orders.length === 0
? new InlineKeyboard().text("🛒 browse the shop", nav.pack({ to: "shop" })).row().text("◀ back", nav.pack({ to: "home" }))
: ordersKeyboard(orders);commands as a shortcut menu
register a short list (≤ 5) so telegram's / menu shows it, localize it if the bot
is multilingual, and scope admin commands to admins. every command must also be reachable by a
button. @yaebal/commands keeps the handlers and the menu
in one definition and only pushes it when it changed.
import { Commands } from "@yaebal/commands";
const commands = new Commands()
.add("start", "open the main menu", start)
.add("settings", "open settings", settings)
.add("help", "how this bot works", help);
bot.install(commands.plugin());
bot.onStart(() => commands.sync(bot.api)); // pushes the menu only when it changedtext hierarchy
boldfor the screen title only — bolding everything means nothing is bold.blockquotefor context;expandableBlockquotefor long disclosures.italicfor metadata: state, dates, versions.code/prefor values people copy — pair them with acopyTextbutton.- links for external urls only; in-bot navigation is buttons.
- build text with
formatand entities — never mix it withparse_mode.
button labels
- short — about 20 characters; phones cut long labels mid-word.
- one emoji vocabulary:
⚙️settings,◀back,🏠home,✅/⬜toggles,🗑destructive,❌cancel. - verbs for actions ("delete account"), nouns for destinations ("settings").
- 2–3 buttons per row for longer labels, up to 4 for short ones;
.columns(n)for grids. - button styles (
"danger","success","primary") for meaning, not decoration.
groups are different
- reply to the triggering message so people see what the bot answered —
ctx.replydoes. - only edit the message tied to the current user's button press; edits are public.
- be quieter:
disable_notificationfor routine posts. - privacy mode means the bot only sees commands and mentions — turn it off in botfather only if you need every message.
- key per-user state per chat and user (the
scenes/conversationdefault), not per chat.
before you ship
/startis a short hero plus buttons.- every screen below home has back; anything deeper than two levels has home.
- navigation edits; only events send new messages.
- every callback is answered — or
@yaebal/auto-answeris installed. - destructive actions go through a confirm screen with the safe option first.
- empty lists have a message and a next step.
- slow actions show a placeholder immediately.
- the command menu is short, registered on start, and mirrored by buttons.
related
- @yaebal/keyboard — the builders used above
- @yaebal/callback-data — typed payloads for every button
- deep links — landing users on a specific screen from outside telegram
- recipes — whole bots built this way