Skip to content

Design

A Mini App sits inside another app's chrome, on a surface whose height changes while the user drags it. Two things separate one that feels native from one that feels like a website in a box: it takes its colours from the host, and it never assumes its own size.

Not implemented

Mini Apps do not run yet — there is no client runtime and no SDK to include. See implementation status.

Theme parameters

The client passes theme_params at launch and re-sends it as theme_changed whenever the theme changes — including mid-session, when the user flips to dark mode without closing your app. Your page has to handle that, not just read the values once.

Values are hex RGB without alpha. The minimum set:

TokenUsed for
bg_colorPage background
secondary_bg_colorGrouped or inset backgrounds
section_bg_colorSection container background
text_colorPrimary text
hint_colorSecondary and placeholder text
subtitle_text_colorSubtitles under a primary line
link_colorLinks
accent_text_colorAccented text
destructive_text_colorDestructive actions
button_colorButton fill
button_text_colorButton label
header_bg_colorHeader
bottom_bar_bg_colorBottom bar
section_header_text_colorSection header labels
section_separator_colorSeparators between sections

The concrete GROM values are specified as coming from the grom-ios-design token set rather than being listed in the TZ, so no palette is published here. That is a real gap for anyone building a static mock: you can build against the token names now, but not against the colours they resolve to.

Push them into CSS custom properties once and style against those:

js
function applyTheme(params) {
  const root = document.documentElement
  for (const [key, value] of Object.entries(params || {})) {
    root.style.setProperty(`--grom-${key.replace(/_/g, '-')}`, value)
  }
}
css
:root {
  /* Fallbacks matter: a token can be absent, and an unstyled page is worse
     than an approximate one. */
  --grom-bg-color: #ffffff;
  --grom-text-color: #000000;
}

body {
  background: var(--grom-bg-color);
  color: var(--grom-text-color);
}

Do not hardcode a palette

The most common way a Mini App looks wrong is a page that ships light-mode colours and inherits a dark host. Every colour in your UI should trace back to a token or to a fallback you chose deliberately — including borders, shadows and disabled states, which are the ones people forget.

hint_color on bg_color is not guaranteed to meet a contrast target. Check the pairs you actually use rather than assuming the host has done it for you.

Viewport

The Mini App surface is not a fixed rectangle. It opens partly expanded, the user can drag it, and viewport_changed fires throughout with height, is_expanded and is_state_stable.

is_state_stable is the field that matters. It is false for every intermediate frame of a drag or animation and true on the final one. Laying out on every event produces visible thrash; laying out only on stable events produces a UI that settles cleanly.

js
let pending = null

function onViewportChanged({ height, is_state_stable }) {
  document.documentElement.style.setProperty('--grom-viewport', `${height}px`)
  if (!is_state_stable) return

  // Expensive relayout, virtualized list remeasure, canvas resize — here only.
  clearTimeout(pending)
  pending = setTimeout(relayout, 0)
}

100vh is wrong here

100vh resolves against the browser viewport, not the Mini App surface. Inside a WebView that is dragged, 100vh is routinely taller than what the user can see, so your footer or submit button ends up below the fold with no way to reach it.

Use the height from viewport_changed. This is the single most common layout bug in Mini Apps and it does not reproduce on a desktop browser, which is where most of the development happens.

Send web_app_expand when your content needs the full surface. Do not send it on load out of habit — an app that grabs the whole screen to show three rows reads as pushy.

Safe areas

Two separate inset sets, and they are not interchangeable:

EventDescribes
safe_area_changedDevice insets — notch, home indicator, rounded corners
content_safe_area_changedInsets imposed by the client's own chrome — header, bottom bar

Both carry top, bottom, left, right. Both are requestable on demand with web_app_request_safe_area and web_app_request_content_safe_area.

Background surfaces should extend under both — that is what makes the app look continuous with the host. Interactive and readable content must respect both, or a button ends up under the home indicator where a swipe reaches it first.

css
.page {
  /* Background bleeds to the edges; content is inset. */
  padding-top: calc(var(--grom-content-safe-top, 0px) + 16px);
  padding-bottom: calc(var(--grom-safe-bottom, 0px) + 16px);
  padding-left: max(var(--grom-safe-left, 0px), 16px);
  padding-right: max(var(--grom-safe-right, 0px), 16px);
}

Landscape and orientation changes shift the horizontal insets, not just the vertical ones. If you only inset top and bottom, a rotated phone will clip your content against the notch.

Fullscreen and orientation

web_app_request_fullscreen and web_app_exit_fullscreen drive fullscreen; the client answers with fullscreen_changed carrying is_fullscreen and an optional blur_enabled, or fullscreen_failed with UNSUPPORTED or ALREADY_FULLSCREEN.

Handle both failures. ALREADY_FULLSCREEN means your state tracking has drifted from the client's — trust the event, not your own flag. UNSUPPORTED means this platform will never grant it, so the request should not be retried and the UI should not offer it again.

web_app_toggle_orientation_lock locks orientation. Worth it for a game or a camera view; irritating anywhere else, since it overrides a system-level preference the user set on purpose.

Deep links can open an app directly in compact or fullscreen mode. Your first paint should be correct in either — you do not get to choose which one the user's link had.

Chrome colours

web_app_set_header_color, web_app_set_background_color and web_app_set_bottom_bar_color tint the native chrome around your page. Set them to match your actual background, and update them when the theme changes, or you get a seam between the header and the page directly under it.

For a main Mini App, the platform draws a loading screen before your page renders, using the bot's stored background_color, background_dark_color, header_color, header_dark_color and a placeholder_path SVG silhouette. Match your first paint to those stored colours. If they disagree, every launch begins with a flash — the one frame users reliably notice.

Practical guidance

Use the native buttons. web_app_setup_main_button puts your primary action where the platform puts every primary action, at a size and position users already know. A custom submit button floating in your own layout has to solve keyboard overlap, safe areas and viewport changes by itself, and will solve at least one of them wrong.

Use the native back button. web_app_setup_back_button integrates with the system gesture. An in-page back arrow does not, so the user's swipe closes the whole app instead of going back one screen.

Confirm before closing, but only when it is true. web_app_setup_closing_behavior adds a confirmation prompt. Enable it when there is unsaved work and disable it the moment there is not. A prompt that always appears gets dismissed reflexively, which is the same as not having one.

Send web_app_ready when you are actually ready. It tells the client to drop the loading screen. Sending it before your first meaningful paint trades a branded placeholder for a blank white rectangle.

Fail visibly. UNSUPPORTED is a normal answer, not an exception — secure storage does not exist on web, sensors do not exist on many devices, fullscreen is not universal. Feature-detect by trying and handling the failure, and make sure the fallback path is one you have looked at.