All posts

MCP App Styling: Host CSS Variables, Dark Mode, and Native-Looking UIs

Abe Wheeler
MCP Apps MCP App Framework ChatGPT Apps ChatGPT App Framework Claude Apps Reference
MCP App resources styled with host CSS variables look native in ChatGPT and Claude.

MCP App resources styled with host CSS variables look native in ChatGPT and Claude.

TL;DR: Use useTheme to detect dark or light mode. Use the host-provided CSS variables (--color-background-primary, --color-text-primary, --font-sans, etc.) for colors, typography, and spacing. Your component then matches each host’s visual style automatically, in both themes, across ChatGPT, Claude, and any future host.

When developers first build an MCP App, they focus on functionality: the tool returns data, the component renders it. Then they open the inspector, switch to dark mode, and see white cards on a dark background. Their app looks out of place.

The fix is not complicated. MCP App hosts publish a design token system through CSS variables. Use those variables instead of hardcoded colors and your component looks native in any host, in any theme, without conditional logic.

useTheme: The Simple Case

If you just need to branch on light vs dark, useTheme is the hook for it.

import { useTheme, useToolData, SafeArea } from 'sunpeak';

export function StatusResource() {
  const theme = useTheme(); // 'light' | 'dark'
  const { output } = useToolData<unknown, { status: string; message: string }>(undefined, undefined);

  if (!output) return null;

  return (
    <SafeArea
      className={`p-5 font-sans ${theme === 'dark' ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'}`}
    >
      <p className="text-sm font-medium">{output.status}</p>
      <p className="text-sm mt-1">{output.message}</p>
    </SafeArea>
  );
}

This works. But you’re now maintaining two color branches. If you use this pattern across a large app, you end up with a lot of ternaries.

The host CSS variables eliminate that entirely.

Host CSS Variables: Use the Design System the Host Already Has

Every MCP App host injects CSS variables into your iframe. These variables represent the host’s design tokens and update automatically when the theme changes. You reference them with var() and the host handles the rest.

The variable categories:

Colors

/* Backgrounds */
--color-background-primary
--color-background-secondary
--color-background-tertiary
--color-background-ghost

/* Text */
--color-text-primary
--color-text-secondary
--color-text-tertiary
--color-text-ghost

/* Borders */
--color-border-primary
--color-border-secondary

/* Semantic states */
--color-background-info
--color-background-danger
--color-background-success
--color-background-warning
--color-text-info
--color-text-danger
--color-text-success
--color-text-warning
--color-border-info
--color-border-danger
--color-border-success
--color-border-warning

Typography

--font-sans
--font-mono
--font-weight-normal
--font-weight-medium
--font-weight-semibold
--font-weight-bold

/* Text sizes — each comes with a matching line height */
--text-xs
--text-sm
--text-md
--text-lg

/* Heading sizes */
--heading-xs
--heading-sm
--heading-md
--heading-lg
--heading-xl
--heading-2xl
--heading-3xl

Spacing and decoration

--border-radius-xs
--border-radius-sm
--border-radius-md
--border-radius-lg
--border-radius-xl
--border-radius-full

--border-width-regular

--shadow-hairline
--shadow-sm
--shadow-md
--shadow-lg

All variables are optional. Hosts may provide any subset depending on their implementation. Use sensible fallbacks where needed.

Rewriting the Status Card With Variables

Here is the same status card using variables instead of conditional classes:

import { useToolData, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  description: 'Show a status message',
};

interface StatusData {
  status: string;
  message: string;
  level: 'info' | 'success' | 'warning' | 'danger';
}

export function StatusResource() {
  const { output } = useToolData<unknown, StatusData>(undefined, undefined);

  if (!output) return null;

  return (
    <SafeArea
      style={{
        padding: '1.25rem',
        fontFamily: 'var(--font-sans)',
        background: 'var(--color-background-primary)',
        color: 'var(--color-text-primary)',
      }}
    >
      <div
        style={{
          padding: '0.75rem 1rem',
          borderRadius: 'var(--border-radius-md)',
          border: '1px solid var(--color-border-primary)',
          background: `var(--color-background-${output.level})`,
          color: `var(--color-text-${output.level})`,
        }}
      >
        <p style={{ fontSize: 'var(--text-sm)', fontWeight: 'var(--font-weight-medium)' }}>
          {output.status}
        </p>
        <p style={{ fontSize: 'var(--text-sm)', marginTop: '0.25rem', color: 'var(--color-text-secondary)' }}>
          {output.message}
        </p>
      </div>
    </SafeArea>
  );
}

No useTheme. No ternaries. The colors adapt to light and dark mode because the host CSS variables change when the theme changes. The semantic variants (--color-background-danger, --color-text-success) give you state-aware styling without hardcoding red or green.

SafeArea and the Host Font

SafeArea from sunpeak is the right root element for almost every resource. It handles safe rendering boundaries — padding for safe area insets, correct box model behavior — across all hosts.

One thing it does not do automatically: set the font. The host injects font-face definitions for --font-sans and --font-mono into your iframe, but you need to apply the variable:

<SafeArea style={{ fontFamily: 'var(--font-sans)' }}>
  {/* content */}
</SafeArea>

Without this, your component uses the iframe default font (usually Times New Roman). With it, your component uses the same typeface as the ChatGPT or Claude interface.

Using Tailwind CSS With Host Variables

If your project uses Tailwind (the default in sunpeak projects), you can set up aliases in tailwind.config.ts to reference host variables as Tailwind tokens:

// tailwind.config.ts
export default {
  theme: {
    extend: {
      colors: {
        'host-bg': 'var(--color-background-primary)',
        'host-bg-secondary': 'var(--color-background-secondary)',
        'host-text': 'var(--color-text-primary)',
        'host-text-secondary': 'var(--color-text-secondary)',
        'host-border': 'var(--color-border-primary)',
        'host-danger-bg': 'var(--color-background-danger)',
        'host-danger-text': 'var(--color-text-danger)',
        'host-success-bg': 'var(--color-background-success)',
        'host-success-text': 'var(--color-text-success)',
      },
      borderRadius: {
        host: 'var(--border-radius-md)',
        'host-lg': 'var(--border-radius-lg)',
      },
      fontFamily: {
        host: ['var(--font-sans)'],
        'host-mono': ['var(--font-mono)'],
      },
      boxShadow: {
        host: 'var(--shadow-md)',
      },
    },
  },
};

Now you write components with Tailwind classes that automatically track the host theme:

<SafeArea className="p-5 font-host bg-host-bg text-host-text">
  <div className="rounded-host border border-host-border shadow-host p-4 bg-host-bg-secondary">
    <p className="text-host-text font-medium">{output.title}</p>
    <p className="text-host-text-secondary text-sm mt-1">{output.description}</p>
  </div>
</SafeArea>

This approach scales. When you add a new component, it inherits the same token system without any extra configuration.

Reading Platform and Viewport Context

Sometimes styling depends on more than just the theme. useHostContext gives you the rest of the picture:

import { useHostContext, useToolData, SafeArea } from 'sunpeak';

export function DataTableResource() {
  const ctx = useHostContext();
  const { output } = useToolData<unknown, { rows: Array<Record<string, string>> }>(undefined, undefined);

  if (!output) return null;

  const isMobile = ctx?.platform === 'mobile';

  return (
    <SafeArea
      style={{
        fontFamily: 'var(--font-sans)',
        background: 'var(--color-background-primary)',
        padding: isMobile ? '0.75rem' : '1.25rem',
      }}
    >
      {isMobile ? (
        <MobileCardList rows={output.rows} />
      ) : (
        <DesktopTable rows={output.rows} />
      )}
    </SafeArea>
  );
}

The ctx?.platform check ('mobile' | 'desktop' | 'web') lets you deliver different layouts for different devices without a media query. ctx?.locale gives you the user’s language setting. ctx?.timeZone gives you their time zone for date formatting.

For most apps, the convenience hooks are enough:

  • useTheme() for dark/light
  • useDisplayMode() for inline/pip/fullscreen
  • useHostContext() when you need locale, platform, or the full styles object

Testing Both Themes in the Inspector

The sunpeak inspector at localhost:3000 has a theme toggle in the sidebar. Switch between light and dark to see how your variables resolve.

For automated Playwright tests, pass the theme parameter to createInspectorUrl:

import { test, expect } from '@playwright/test';
import { createInspectorUrl } from 'sunpeak/chatgpt';

const themes = ['light', 'dark'] as const;

for (const theme of themes) {
  test(`status card renders correctly in ${theme} mode`, async ({ page }) => {
    await page.goto(
      createInspectorUrl({
        simulation: 'show-status',
        host: 'chatgpt',
        theme,
      })
    );
    const iframe = page.frameLocator('iframe');
    await expect(iframe.locator('text=Operational')).toBeVisible();
  });
}

This runs two tests per simulation: one in light, one in dark. Add host: 'claude' and you cover four configurations with one loop. The testing guide has the full multi-host matrix setup.

No paid ChatGPT or Claude accounts required. Everything runs locally and in CI.

A Complete Component Using Host Variables

Here is a complete ticket card resource using the full token system:

import { useToolData, useDisplayMode, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  description: 'Display a support ticket',
};

interface TicketData {
  id: string;
  title: string;
  status: 'open' | 'closed' | 'in_progress';
  priority: 'low' | 'medium' | 'high';
  assignee: string;
  createdAt: string;
}

const statusColors: Record<string, string> = {
  open: '--color-background-info',
  closed: '--color-background-success',
  in_progress: '--color-background-warning',
};

const statusText: Record<string, string> = {
  open: '--color-text-info',
  closed: '--color-text-success',
  in_progress: '--color-text-warning',
};

export function TicketResource() {
  const { output } = useToolData<unknown, TicketData>(undefined, undefined);
  const displayMode = useDisplayMode();

  if (!output) return null;

  const maxWidth = displayMode === 'fullscreen' ? '640px' : '420px';

  return (
    <SafeArea
      style={{
        fontFamily: 'var(--font-sans)',
        background: 'var(--color-background-primary)',
        color: 'var(--color-text-primary)',
        padding: '1.25rem',
      }}
    >
      <div
        style={{
          maxWidth,
          margin: '0 auto',
          background: 'var(--color-background-secondary)',
          border: '1px solid var(--color-border-primary)',
          borderRadius: 'var(--border-radius-lg)',
          padding: '1rem',
          boxShadow: 'var(--shadow-sm)',
        }}
      >
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
          <h1
            style={{
              fontSize: 'var(--text-md)',
              fontWeight: 'var(--font-weight-semibold)',
              margin: 0,
            }}
          >
            {output.title}
          </h1>
          <span
            style={{
              fontSize: 'var(--text-xs)',
              fontWeight: 'var(--font-weight-medium)',
              padding: '0.25rem 0.5rem',
              borderRadius: 'var(--border-radius-sm)',
              background: `var(${statusColors[output.status]})`,
              color: `var(${statusText[output.status]})`,
              whiteSpace: 'nowrap',
            }}
          >
            {output.status.replace('_', ' ')}
          </span>
        </div>

        <div
          style={{
            marginTop: '0.75rem',
            fontSize: 'var(--text-sm)',
            color: 'var(--color-text-secondary)',
            display: 'flex',
            gap: '1rem',
          }}
        >
          <span>#{output.id}</span>
          <span>{output.assignee}</span>
          <span>{new Date(output.createdAt).toLocaleDateString()}</span>
        </div>
      </div>
    </SafeArea>
  );
}

No useTheme. No hardcoded colors. This component renders with the correct colors in dark ChatGPT, light ChatGPT, dark Claude, and light Claude — and any future host — without a single conditional.

Get Started

Documentation →
pnpm add -g sunpeak && sunpeak new

Further Reading

Frequently Asked Questions

How do I implement dark mode in an MCP App?

Use the useTheme hook from sunpeak to detect the current host theme. It returns either "light" or "dark". Apply conditional CSS classes or use the host-provided CSS variables (--color-background-primary, --color-text-primary, etc.) in your component — these automatically adapt to the current theme without any conditional logic.

What CSS variables does the MCP App host provide?

MCP App hosts provide a full design token system via CSS variables: color tokens (--color-background-primary, --color-text-secondary, --color-border-primary, and state variants for info, danger, success, warning), typography tokens (--font-sans, --font-mono, font weights, text sizes with line heights), spacing tokens (--border-radius-xs through --border-radius-xl), and shadow tokens (--shadow-sm, --shadow-md, --shadow-lg). Use these to match the host visual style automatically across ChatGPT, Claude, and any other MCP-compatible host.

What is the useTheme hook in sunpeak?

useTheme is a React hook imported from sunpeak that returns the current host theme as "light" or "dark". It defaults to "light" when the theme is unavailable. It is a convenience wrapper around useHostContext, which provides the full host context including theme, locale, platform, viewport, and CSS variables.

Do I need to handle dark mode separately for ChatGPT and Claude?

No. The MCP App protocol standardizes how hosts communicate theme to your app. The useTheme hook and host CSS variables work the same way on ChatGPT, Claude, Goose, and any other MCP App host. Write one set of styles and they adapt to every host and theme automatically.

How do I use the host font in my MCP App?

Apply font-family: var(--font-sans) to your component root or body. The host injects its own font-face definitions into your iframe via the styles.css field in McpUiHostContext. SafeArea from sunpeak applies this automatically when you use it as the root element. For monospace text, use var(--font-mono).

How do I test MCP App dark mode locally?

Use sunpeak dev to start the local inspector at localhost:3000. Toggle the theme in the sidebar dropdown to switch between light and dark mode. For automated tests, pass theme: "dark" or theme: "light" to createInspectorUrl in your Playwright test file. Both themes run in CI without any paid accounts or AI credits.

What is useHostContext and when should I use it instead of useTheme?

useHostContext returns the full McpUiHostContext object including theme, displayMode, locale, timeZone, platform, deviceCapabilities, safeAreaInsets, containerDimensions, and the styles object with CSS variables. Use useTheme, useDisplayMode, and other convenience hooks for most cases. Use useHostContext directly when you need multiple fields from the context at once, or when you need access to platform, locale, or deviceCapabilities data.

How do I make my MCP App look native in both ChatGPT and Claude?

Use the host CSS variables (--color-background-primary, --color-text-primary, --border-radius-md, etc.) throughout your component instead of hardcoded hex colors. Wrap your component in SafeArea from sunpeak, which handles safe rendering boundaries across hosts. Use var(--font-sans) for text. The result is a component that matches each host visual style automatically.