Custom Theming
read as.mdGGUI theming has two independent layers, and they live on opposite sides of the iframe boundary. Keep them straight:
- Your host chrome — the React app around the render (the chat panel, the surrounding page). You theme this with
@ggui-ai/designtokens: a<ThemeProvider>injects--ggui-*CSS variables on:rootand your CSS references them. - The generated UI inside the iframe — the component the agent rendered. You never style this from the host (it’s sandboxed on a different origin). Its palette comes from the operator’s
ggui.jsonthemepreset, resolved per render and merged into the scoped block of--ggui-*variables the components read. Its light/dark mode belongs to the embedding host: when the host announces a mode, that mode wins.
The two are matched by convention: the ggui-basic-web sample picks the same preset for its host chrome that the demo ggui.json picks for the iframe, so the chat shell and the generated card share one palette.
For the full token reference (palettes, scales, semantic colors), see Design Tokens.
Layer 1 — host chrome via <ThemeProvider>
Section titled “Layer 1 — host chrome via <ThemeProvider>”<ThemeProvider> (from @ggui-ai/design/themes) takes a raw DTCG token tree and a color mode, injects the theme’s --ggui-* CSS variables on :root (the set in the token reference below), and wires the base html, body font + surface colors. Pull a registered preset with getRawTheme(id, mode):
import { ThemeProvider, getRawTheme } from "@ggui-ai/design/themes";
// Resolve the raw DtcgTheme token tree for a registered preset.const INDIGO_DARK = getRawTheme("indigo", "dark");
function App() { return ( <ThemeProvider theme={INDIGO_DARK} mode="dark"> {/* Your chat panel / page chrome. Every CSS rule that references a --ggui-* variable now resolves against the indigo/dark tokens. */} </ThemeProvider> );}Your stylesheet then references the injected variables — the shape the sample’s globals.css uses to map semantic chat-shell roles onto GGUI tokens. Always supply the hardcoded fallback, as the sample does: it is what paints when no theme has been injected yet.
:root { --bg-app: var(--ggui-color-ground, #ffffff); --bg-chat: var(--ggui-color-sunken, #f3f4f6); --accent: var(--ggui-color-primary-500, #0ea5e9); --fg: var(--ggui-color-onGround, #111827); --fg-muted: var(--ggui-color-onSunken, #6b7280);}
.chat { background: var(--bg-chat); color: var(--fg); font-family: var(--ggui-font-family-sans);}Discovering presets
Section titled “Discovering presets”@ggui-ai/design/themes ships a registry — list what’s available at runtime (e.g. to build a theme picker):
import { getThemeIds, listThemes, getDefaultThemeId } from "@ggui-ai/design/themes";
getThemeIds(); // ['ggui', 'indigo', 'claudic', …] — every registered idlistThemes(); // [{ id, name, description, modes }, …] — metadata for a pickergetDefaultThemeId(); // 'ggui'Layer 2 — generated iframe content via ggui.json
Section titled “Layer 2 — generated iframe content via ggui.json”You can’t reach into the sandboxed iframe with CSS. Instead, the operator’s ggui.json theme block sets the app’s default preset. At each render the server resolves the theme (an operator’s live pick in the console, where the deployment offers one → the per-render themeId override → the app’s ggui.json default → the shipped default) and snapshots it onto the GguiSession. The iframe-runtime then merges the theme into the scoped token block it mounts inside the render’s scope element, which is the block the generated components read. It also copies the variables onto the iframe’s :root, but that copy only styles the body chrome around the component. Generated components reference the same token variables your host chrome does, so the palette is a runtime overlay, not generated code:
{ "schema": "1", "protocol": "draft-2026-09-10", "app": { "slug": "my-app", "name": "My App" }, "theme": { "preset": "indigo", "mode": "dark" }}theme also accepts a bare string preset shorthand ("theme": "indigo"), an overrides map of flat dot-path token tweaks on top of a preset ({ "preset": "indigo", "mode": "dark", "overrides": { "color.primary.500": "#8b5cf6" } } — no custom theme file needed), and a { "file": "./theme.json", "mode": "dark" } form pointing at a DTCG theme file. It also accepts null. Locally, "theme": null means the same as leaving theme out: the shipped default tokens. On ggui deploy it clears the app’s stored theme, while leaving theme out leaves the stored theme untouched. Lint a theme file before serving with ggui theme validate <path>.
Match theme.preset here to the preset you pass to getRawTheme(...) for <ThemeProvider> in your host, and the chat shell and the rendered card render in one cohesive palette — the sample does exactly this.
You don’t need to match theme.mode by hand. It is only a default: when the embedding host announces a mode (the MCP Apps hostContext.theme), the host’s mode wins, so the card follows the host’s light/dark switch. From highest to lowest, the mode comes from the host’s announced mode, then an operator’s live pick in the console, then ggui.json’s theme.mode, then the mode stored with the app’s theme.
Per-render theme override
Section titled “Per-render theme override”The preset isn’t fixed per app. Agents can list the available presets with the ggui_list_themes tool (each entry is { id, name, description, modes }) and pick one for a single render via the themeId input on ggui_render — the override wins over the app default for that render only. An operator’s live pick in the console, where the deployment offers one, still outranks it. App.availableThemeIds allowlists which presets agents may pick.
Ad-hoc CSS token overrides
Section titled “Ad-hoc CSS token overrides”The token layer is just CSS custom properties with hardcoded fallbacks, so you can also override individual variables without a registered preset — useful for a quick rebrand on top of a chosen theme, or for scoping one subtree.
Global override
Section titled “Global override”Set tokens on :root to restyle every host-chrome surface in the page:
:root { /* Primary brand color (50 → 900 scale) */ --ggui-color-primary-50: #eff6ff; --ggui-color-primary-500: #3b82f6; --ggui-color-primary-600: #2563eb; --ggui-color-primary-900: #1e3a8a;
/* Typography */ --ggui-font-family-sans: "Inter", system-ui, sans-serif; --ggui-font-family-mono: "JetBrains Mono", ui-monospace, monospace;
/* Shape + density */ --ggui-spacing-md: 16px; --ggui-shape-radius-md: 8px; --ggui-shape-shadow-md: 0 8px 16px -4px rgba(15, 23, 42, 0.1);}These run on top of whatever <ThemeProvider> injected — the provider writes the :root block first, your stylesheet’s later :root rules win on equal specificity.
Scoped theming
Section titled “Scoped theming”CSS custom properties cascade — wrap any subtree to give it its own accent without leaking to siblings:
<section style={ { "--ggui-color-primary-600": "#8b5cf6", "--ggui-shape-radius-md": "1rem", } as React.CSSProperties }> {/* Everything in here picks up the purple primary + softer corners */}</section>Token reference
Section titled “Token reference”| Category | Pattern | Example |
|---|---|---|
| Colors (primary) | --ggui-color-primary-{50-900} |
--ggui-color-primary-600 |
| Colors (neutral) | --ggui-color-neutral-{50,100,200,300,400,500,600,900} |
--ggui-color-neutral-200 |
| Colors (status) | --ggui-color-{success,warning,info}-{50,100,200,500,700,800} and --ggui-color-error-{50,100,200,500,600,700,800} |
--ggui-color-success-500 |
| Layering roles | --ggui-color-{ground,onGround,container,onContainer,elevated,onElevated,sunken,onSunken,outline,outlineVariant} — ground is the canvas, container the card, sunken the well; elevated is derived |
--ggui-color-onGround |
| Role pairs | --ggui-color-{onPrimary,primaryContainer,onPrimaryContainer,onError,errorContainer,onErrorContainer,tertiaryContainer,onTertiaryContainer} and the {success,warning,info}Container pairs with their on* inks |
--ggui-color-onPrimary |
| Links | --ggui-color-{link,inverseLink} |
--ggui-color-link |
| Typography | --ggui-font-family-{sans,heading,mono}, --ggui-font-size-{xs,sm,base,lg,xl,2xl,3xl,4xl}, --ggui-font-weight-{normal,medium,semibold,bold,heading}, --ggui-font-lineHeight-normal, --ggui-letter-spacing-heading — sizes come from the one font.ramp; a theme authors font.{family,weight,ramp,letterSpacing} only |
--ggui-font-size-lg |
| Spacing | --ggui-spacing-{xs,sm,md,lg,xl,2xl} and the numeric steps --ggui-spacing-{1,2,3,4,5,6,8,10,12} |
--ggui-spacing-md |
| Shape — radius | --ggui-shape-radius-{sm,md,lg,xl,full,control} |
--ggui-shape-radius-lg |
| Shape — shadow | --ggui-shape-shadow-{sm,md,lg,xl} |
--ggui-shape-shadow-md |
| Motion — duration | --ggui-motion-duration-{fast,base,slow} |
--ggui-motion-duration-base |
| Motion — easing | --ggui-motion-easing-{standard,emphasized,exit} |
--ggui-motion-easing-standard |
| Scrim | --ggui-scrim-{tint,opacity} — the tint laid between the host page and the card |
--ggui-scrim-opacity |
Status colors are ramps derived from one anchor — a theme authors a single 500 for each of success, warning, error and info, and the stops listed above, the inks, the containers and the outlines are derived from it. Layering roles are pairs — ground/onGround (the canvas and its text), container/onContainer (cards), sunken/onSunken (wells, inputs at rest, code blocks, secondary text) — with elevated/onElevated derived; use them to keep contrast right across nested backgrounds. Motion is a bounded tempo override. A theme may state motion.duration.{fast,base,slow} and motion.easing.{standard,emphasized,exit}, and since v0.21.0 the card animates at the stated tempo: every design-system transition reads the six --ggui-motion-* variables. A theme that states none animates at the shipped tempo. A theme.json in the old shape (surface/surfaceVariant, authored font.size / font.lineHeight, a free-form motion.duration / motion.easing ladder such as instant…slower, $metadata.fontUrl) is refused: ggui serve refuses to start on it, and ggui theme validate <path> names the field.
See Design Tokens for live swatches and the complete scale.
See also
Section titled “See also”- Design Tokens — full palette, scales, and live swatches
- React host helpers —
<AppRenderer>+useMcpAppsChat, the web render-hosting surface ggui-basic-websample — the runnable reference that matches host chrome toggui.json