Concepts

Theming MCP Apps: dark mode and host styles

The host tells your view whether the chat is light or dark and hands it a set of CSS variables. Follow them and your widget looks native in both themes. Ignore them and it looks pasted in.

7 min read

Will I need a jacket this afternoon? I'm walking to the office around five.

Used show_weather

A live MCP Apps widget, rendered the way a host renders it. Made with Widgetry.

How does MCP Apps theming work?

MCP Apps theming works through the host context. When your view connects, the host sends a theme ("light" or "dark"), a set of CSS custom properties in styles.variables (colors, type sizes, radii, shadows) and, optionally, @font-face rules in styles.css.fonts. When the user switches the chat between light and dark, the host sends ui/notifications/host-context-changed with the fields that changed. Your job is to apply those values to the document and to write CSS that reads them. Do that and dark mode in your MCP App comes almost for free.

This is part of the MCP Apps specification. If the protocol itself is new to you, read what MCP Apps are first.

What the host gives your view

The fields that matter for theming:

FieldWhat it holds
theme"light" or "dark": the chat's current theme
styles.variablesCSS custom properties such as --color-background-primary, --color-text-secondary, --font-sans, --border-radius-md, --shadow-sm
styles.css.fontsCSS with @font-face or @import rules for the host's fonts
displayMode, containerDimensions, safeAreaInsetslayout hints: inline or fullscreen, the space you have, the edges to keep clear

The variable names are standardized in the SDK types. They come in families: --color-background-*, --color-text-*, --color-border-* and --color-ring-* (primary, secondary, tertiary, inverse, ghost, plus info, danger, success, warning and disabled), --font-sans and --font-mono, --font-weight-*, --font-text-*-size and --font-heading-*-size with their line heights, --border-radius-*, --border-width-regular and --shadow-*. Hosts may send any subset, so always write a fallback.

Hosts fill them differently. As of October 2026, Claude publishes its full palette in its design guidelines, uses CSS light-dark() in the values and serves its font from https://assets.claude.ai. ChatGPT has supplied host style variables since May 2026, according to its changelog.

Three ways to approach theming

Follow the host fully. Transparent background, host tokens for every color, the host's font. The widget looks like part of the conversation. This is what both big hosts ask for in structural elements. Claude: use host tokens for backgrounds, text, borders and icons, and keep brand colors for accents. OpenAI's UI guidelines: use system colors for text, icons and dividers, put brand colors on accents, badges and primary buttons, and do not use custom fonts.

Your own brand, in light and dark. Your palette, your type, two versions switched by the host theme. It suits visual widgets (a weather card, a 3D globe, a branded receipt) where the look is the point. The cost is that the widget can feel like a foreign object, and you maintain two palettes.

Hybrid. Host tokens for text, borders and surfaces; your own colors for the accent and for data series. For most widgets this is the right default: native structure, recognizable details.

Whichever you pick, Claude's guidelines are explicit that every view must support both light and dark themes.

SDK helpers: applyDocumentTheme, useHostStyles and friends

@modelcontextprotocol/ext-apps does the DOM work for you:

  • applyDocumentTheme(theme) sets data-theme and color-scheme on <html>, so [data-theme="dark"] selectors and light-dark() values resolve. getDocumentTheme() reads it back.
  • applyHostStyleVariables(variables) writes every entry of styles.variables onto :root.
  • applyHostFonts(css) injects the host's font rules once.

In plain TypeScript, apply the initial context after connect() and every change after that. Register the listener before connecting so you do not miss an early update:

ts
import { App, applyDocumentTheme, applyHostFonts, applyHostStyleVariables, type McpUiHostContext } from '@modelcontextprotocol/ext-apps'

const app = new App({ name: 'weather-card', version: '1.0.0' })

function applyHostContext(context: Partial<McpUiHostContext>) {
  if (context.theme) applyDocumentTheme(context.theme)
  if (context.styles?.variables) applyHostStyleVariables(context.styles.variables)
  if (context.styles?.css?.fonts) applyHostFonts(context.styles.css.fonts)
}

// A change carries only the fields that changed.
app.addEventListener('hostcontextchanged', applyHostContext)

await app.connect()
applyHostContext(app.getHostContext() ?? {})

In React, @modelcontextprotocol/ext-apps/react wraps the same helpers. useHostStyles(app, app?.getHostContext()) applies theme, variables and fonts and re-applies them on every change (useHostStyleVariables and useHostFonts do one half each), and useDocumentTheme() gives you the current theme as state, for the rare component that must branch on it:

tsx
import { useApp, useDocumentTheme, useHostStyles } from '@modelcontextprotocol/ext-apps/react'

function WeatherCard() {
  const { app } = useApp({ appInfo: { name: 'weather-card', version: '1.0.0' }, capabilities: {} })
  useHostStyles(app, app?.getHostContext())
  const theme = useDocumentTheme()
  return <article className="card">{theme === 'dark' ? 'Clear night' : 'Sunny'}</article>
}

CSS that works in light and dark

Layer your own tokens on top of the host's, with fallbacks for hosts that send fewer variables and for the moment before the context arrives:

css
:root {
  --card-bg: var(--color-background-primary, #ffffff);
  --card-ink: var(--color-text-primary, #1d1d1b);
  --card-muted: var(--color-text-secondary, #5d5d58);
  --card-line: var(--color-border-tertiary, #e6e6e2);
  --card-accent: #2f6fd6;
}
:root[data-theme="dark"] {
  --card-bg: var(--color-background-primary, #1f1f1d);
  --card-ink: var(--color-text-primary, #f1f1ee);
  --card-muted: var(--color-text-secondary, #b3b3ad);
  --card-line: var(--color-border-tertiary, #383834);
  --card-accent: #7aa9f5;
}
html, body { margin: 0; background: transparent; }
.card {
  background: var(--card-bg);
  color: var(--card-ink);
  border: var(--border-width-regular, 1px) solid var(--card-line);
  border-radius: var(--border-radius-lg, 12px);
  font-family: var(--font-sans, system-ui, sans-serif);
}
.card svg { fill: currentColor; }

A few rules that save debugging time:

  • Declare color-scheme. Put <meta name="color-scheme" content="light dark"> in the head. Claude's theming guide explains why: browsers paint an opaque backdrop behind an iframe whose color scheme differs from the page, and the tag covers the first paint before your script runs. applyDocumentTheme sets it again at run time.
  • Follow the host theme, not prefers-color-scheme. Inside the iframe, the media query answers from the operating system and the browser. A person can run the chat app in dark mode on a light system, or the other way round. The host's theme is the value the host commits to. Use the media query only as a fallback when no host is talking to you.
  • Keep the background transparent unless you mean to paint a card. Every frame Claude puts between your widget and the chat is transparent already.
  • Use currentColor in icons, so they follow the text color in both themes.
  • Redraw canvas on theme change. CSS variables update by themselves; a chart drawn on a canvas read its colors once. Read them with getComputedStyle when you draw, and draw again on hostcontextchanged.

Borders, spacing and size

Borders. _meta.ui.prefersBorder on the resource asks the host for a visible border and background (true) or none (false). If you omit it, the host decides, and the spec recommends setting it because defaults vary. In Claude, an unset value renders borderless on web and bordered on mobile. Borderless means edge to edge with no host padding, so respect safeAreaInsets. Inside the widget, Claude asks for a limited set of corner radii and border thicknesses; the host's --border-radius-* and --border-width-regular tokens give you one.

Spacing. Claude asks for generous padding and logical groupings; OpenAI asks for consistent padding and no cramming or edge-to-edge text. Both prefer a small type scale (heading, body, caption) over many sizes.

Size. Do not fix the height. Inline cards in Claude auto-fit their content, have no nested scrolling, and the host caps and clips anything taller. OpenAI lets a card grow to its content up to the height of the mobile display area. Your view reports its height to the host with ui/notifications/size-changed; the SDK's App sends it for you whenever the content changes, unless you turn autoResize off. If you need a scrollable viewport, request fullscreen with ui/request-display-mode.

Contrast. Claude asks for WCAG AA contrast at minimum. Check both themes, especially accent colors on dark surfaces.

How Widgetry handles theming

Widgetry, a designer for MCP Apps widgets, separates the two questions a theme answers: what the widget is (its type: board, chart, weather card, globe) and how it looks (its design). Every type works in every design.

  • A design is a set of --wg-* tokens: canvas, three levels of ink, three surfaces, line color and width, two radii, shadow, glass blur, accent and the text on it, five data series, positive and negative, three font stacks, heading weight, tracking and case, and a glow. Widget styles read only those tokens, which is why one type can switch design without touching its markup.
  • The defaults follow the host. Before any design applies, --wg-ink is var(--color-text-primary), --wg-surface is var(--color-background-primary), and so on, with values in :where() so any design overrides them. The runtime applies the host's theme, variables and fonts with the SDK helpers above and re-renders scripted widgets when the theme changes, so canvas charts redraw.
  • Twenty built-in designs: the house design, five classics (Nordic, Aurora, Brutalist, Editorial, Neon) and fourteen inspired by well-known brands, which take only colors, radii, weights and type character, never logos.
  • Light and dark per design. Thirteen of the twenty switch with the chat theme through :root[data-theme="dark"]; Nordic, one of them, takes its text and surface colors straight from the host. The other seven commit to one scheme on purpose (Aurora and Neon are always dark, Brutalist always light) and declare their color-scheme so native controls match.
  • A token designer per organization. A custom design starts from a built-in one and stores its own token overrides for light, a separate set for dark, and optional extra CSS for anything tokens cannot say.
  • System fonts only, because a web font would need its origin in every resource CSP. See the MCP Apps CSP guide.
  • A preview that is a real host. The editor speaks MCP Apps to the widget and toggles light and dark through the same host-context-changed notification Claude sends, so you see both themes before you ship.
  • prefersBorder: true on every widget resource Widgetry serves or exports.

The weather card at the top of this page is one type; each design paints its own sky. Browse every type in the templates, and see add a UI to your MCP server to take a themed widget to your own server.

Your first widget, in the chat in minutes

Pick a template, drop in your data and see it as ChatGPT or Claude will show it. Then link it from your MCP server, or let your agent build the next one.