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.

menu.ts
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.

start.ts
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 ◀ back button in the last row of every non-home screen — never a dead end.
  • a 🏠 home button 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.

delete-account.ts
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.

search.ts
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.

orders.ts
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.

commands.ts
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 changed

text hierarchy

  • bold for the screen title only — bolding everything means nothing is bold.
  • blockquote for context; expandableBlockquote for long disclosures.
  • italic for metadata: state, dates, versions.
  • code / pre for values people copy — pair them with a copyText button.
  • links for external urls only; in-bot navigation is buttons.
  • build text with format and entities — never mix it with parse_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.reply does.
  • only edit the message tied to the current user's button press; edits are public.
  • be quieter: disable_notification for 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 / conversation default), not per chat.

before you ship

  • /start is 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-answer is 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