Skip to content

Custom Theming

read as .md

ggui theming has two independent layers, and they live on opposite sides of the iframe boundary. Keep them straight:

  1. Your host chrome — the React app around the render (the chat panel, the surrounding page). You theme this with @ggui-ai/design tokens: a <ThemeProvider> injects --ggui-* CSS variables on :root and your CSS references them.
  2. 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.json theme preset, resolved per render and applied as a runtime CSS-variable overlay.

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 every token as a --ggui-* CSS variable on :root, 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 — exactly how the sample’s globals.css maps semantic chat-shell roles onto ggui tokens:

:root {
--bg-app: var(--ggui-color-background);
--bg-chat: var(--ggui-color-surface);
--accent: var(--ggui-color-primary-500);
--fg: var(--ggui-color-onSurface);
--fg-muted: var(--ggui-color-onSurfaceVariant);
}
.chat {
background: var(--bg-chat);
color: var(--fg);
font-family: var(--ggui-font-family-sans);
}

@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 id
listThemes(); // [{ id, name, description, modes }, …] — metadata for a picker
getDefaultThemeId(); // '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 (per-render themeId override → App default → server fallback), snapshots it onto the GguiSession, and the iframe-runtime applies it as a --ggui-* CSS-variable overlay on the iframe’s :root — 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-06-12",
"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. Lint a theme file before serving with ggui theme validate <path>.

Match theme.preset / theme.mode here to the getRawTheme(...) you pass to <ThemeProvider> in your host, and the chat shell and the rendered card render in one cohesive palette — the sample does exactly this.

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. App.availableThemeIds allowlists which presets agents may pick.

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.

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.

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>
CategoryPatternExample
Colors (primary)--ggui-color-primary-{50-900}--ggui-color-primary-600
Colors (neutral)--ggui-color-neutral-{50-900}--ggui-color-neutral-200
Colors (status)--ggui-color-{success,warning,error,info}-{50,100,200,500,600,700,800}--ggui-color-success-500
Surface + content--ggui-color-{surface,onSurface,surfaceVariant,onSurfaceVariant,container,onContainer,outline,outlineVariant}--ggui-color-onSurface
Typography--ggui-font-{family,size,weight,lineHeight}-*--ggui-font-size-lg
Spacing--ggui-spacing-{xs,sm,md,lg,xl,2xl,3xl}--ggui-spacing-md
Shape — radius--ggui-shape-radius-{none,sm,md,lg,xl,2xl,full}--ggui-shape-radius-lg
Shape — shadow--ggui-shape-shadow-{none,xs,sm,md,lg,xl,2xl}--ggui-shape-shadow-md
Motion — duration--ggui-motion-duration-{instant,fast,normal,slow,slower}--ggui-motion-duration-normal
Motion — transition--ggui-motion-transition-{colors,opacity,transform,all}--ggui-motion-transition-colors
Accessibility--ggui-accessibility-focusRing-{color,width,offset} (plus reducedMotion, highContrast)--ggui-accessibility-focusRing-color
Z-Index--ggui-zIndex-{hide,base,docked,dropdown,sticky,banner,overlay,modal,popover,skipLink,toast,tooltip}--ggui-zIndex-modal

Status colors are scalessuccess, warning, error, and info each ship the full 50/100/200/500/600/700/800 stops (no singletons). Material role pairs (surface/onSurface, surfaceVariant/onSurfaceVariant, container/onContainer, outline/outlineVariant) keep contrast right across nested surfaces — override the pair together, never one half.

See Design Tokens for live swatches and the complete scale.

  • Design Tokens — full palette, scales, and live swatches
  • React SDK<AppRenderer> + useMcpAppsChat, the web render-hosting surface
  • ggui-basic-web sample — the runnable reference that matches host chrome to ggui.json