Appearance
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:
| Token | Used for |
|---|---|
bg_color | Page background |
secondary_bg_color | Grouped or inset backgrounds |
section_bg_color | Section container background |
text_color | Primary text |
hint_color | Secondary and placeholder text |
subtitle_text_color | Subtitles under a primary line |
link_color | Links |
accent_text_color | Accented text |
destructive_text_color | Destructive actions |
button_color | Button fill |
button_text_color | Button label |
header_bg_color | Header |
bottom_bar_bg_color | Bottom bar |
section_header_text_color | Section header labels |
section_separator_color | Separators 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:
| Event | Describes |
|---|---|
safe_area_changed | Device insets — notch, home indicator, rounded corners |
content_safe_area_changed | Insets 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.