Skip to main content
All posts

MCP App Styling: Host CSS Variables, Dark Mode, and Native-Looking UIs (July 2026)

Abe Wheeler
MCP AppsMCP App FrameworkChatGPT AppsChatGPT App FrameworkClaude AppsClaude ConnectorsClaude Connector FrameworkMCP App TestingReference
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 host CSS variables (--color-background-primary, --color-text-primary, --font-sans, etc.) for colors, type, borders, and spacing. Add fallback values because every host token is optional. Use useTheme only when behavior must change between light and dark mode. Test the same resource across host, theme, display mode, and mobile-width combinations before shipping.

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 Apps give the host a standard way to send theme, style variables, fonts, safe area insets, viewport data, and display-mode information to the component iframe. Use those values instead of hardcoded colors and your component can look native in ChatGPT, Claude, and other MCP Apps-compatible hosts. The same standard also keeps the UI portable because ChatGPT-specific APIs are optional extensions, not the foundation.

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

MCP App hosts can send style variables through host context. The MCP Apps SDK applies those values as CSS custom properties, and sunpeak’s React hooks apply them automatically when the host context changes. You reference them with var() and the host supplies values that match its own light mode, dark mode, font stack, and component surfaces.

The variable categories:

Colors

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

/* Text */
--color-text-primary
--color-text-secondary
--color-text-tertiary
--color-text-inverse
--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-background-disabled
--color-text-info
--color-text-danger
--color-text-success
--color-text-warning
--color-text-disabled
--color-border-info
--color-border-danger
--color-border-success
--color-border-warning
--color-border-disabled

Focus rings

These are useful for accessible, theme-aware focus indicators on interactive elements.

--color-ring-primary
--color-ring-secondary
--color-ring-inverse
--color-ring-info
--color-ring-danger
--color-ring-success
--color-ring-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, and a host can change values when the user changes theme or when a component moves to a different surface. Use sensible fallbacks where needed. See the full CSS Variables reference for details.

Add Fallbacks for Every Host Token

The most common styling bug in a cross-host MCP App is assuming a token exists because it exists in the first host you tested. CSS already gives you the right escape hatch:

.resource-root {
  font-family: var(--font-sans, system-ui, sans-serif);
  background: var(--color-background-primary, Canvas);
  color: var(--color-text-primary, CanvasText);
}

.resource-card {
  background: var(--color-background-secondary, color-mix(in srgb, CanvasText 4%, Canvas));
  border: 1px solid var(--color-border-primary, color-mix(in srgb, CanvasText 16%, Canvas));
  border-radius: var(--border-radius-md, 8px);
  box-shadow: var(--shadow-sm, none);
}

Those fallbacks are not just defensive coding. They also help when the UI renders in a host that supports MCP tools and resources but has not implemented the full MCP Apps style token set yet. Your component still looks deliberate, the text stays readable, and the tool result remains useful even if the host gives you fewer design tokens than ChatGPT or Claude.

Use semantic fallbacks with care. If you style a destructive action with --color-background-danger, pair it with a fallback that still communicates danger:

.danger-button {
  background: var(--color-background-danger, #fee2e2);
  color: var(--color-text-danger, #991b1b);
  border-color: var(--color-border-danger, #fecaca);
}

This is also why you should test with a “minimal token” fixture. A component that only looks correct when every token exists is not actually portable.

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, system-ui, sans-serif)',
        background: 'var(--color-background-primary, Canvas)',
        color: 'var(--color-text-primary, CanvasText)',
      }}
    >
      <div
        data-testid="status-card"
        style={{
          padding: '0.75rem 1rem',
          borderRadius: 'var(--border-radius-md, 8px)',
          border:
            '1px solid var(--color-border-primary, color-mix(in srgb, CanvasText 16%, Canvas))',
          background: `var(--color-background-${output.level}, Canvas)`,
          color: `var(--color-text-${output.level}, CanvasText)`,
        }}
      >
        <p
          style={{
            fontSize: 'var(--text-sm, 0.875rem)',
            fontWeight: 'var(--font-weight-medium, 500)',
          }}
        >
          {output.status}
        </p>
        <p
          style={{
            fontSize: 'var(--text-sm, 0.875rem)',
            marginTop: '0.25rem',
            color: 'var(--color-text-secondary, color-mix(in srgb, CanvasText 68%, Canvas))',
          }}
        >
          {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, Host Styles, and the Host Font

SafeArea from sunpeak is the right root element for almost every resource. It handles safe rendering boundaries, safe area padding, viewport width and height constraints, and fullscreen sizing so your root layout does not need to manually combine useSafeArea() and useViewport().

Host style application is separate from layout. useHostContext subscribes to the host context and applies host-provided CSS variables, document theme, and font-face rules to the iframe document. SafeArea handles the box your UI lives in.

You still need to use the font variable in your CSS:

<SafeArea style={{ fontFamily: 'var(--font-sans, system-ui, sans-serif)' }}>
  {/* 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.

If you are using the lower-level MCP Apps SDK directly instead of sunpeak’s React app framework, use useHostStyles or applyHostStyleVariables() to apply the host’s CSS variables, theme, and font-face definitions yourself.

Using Tailwind CSS With Host Variables

If your project uses Tailwind 4, define host-token aliases in CSS with @theme inline. The inline modifier matters because these utilities should resolve against the host variables at runtime:

@import 'tailwindcss';

@theme inline {
  --color-host-bg: var(--color-background-primary, Canvas);
  --color-host-bg-secondary: var(--color-background-secondary, Canvas);
  --color-host-text: var(--color-text-primary, CanvasText);
  --color-host-text-secondary: var(
    --color-text-secondary,
    color-mix(in srgb, CanvasText 68%, Canvas)
  );
  --color-host-border: var(--color-border-primary, color-mix(in srgb, CanvasText 16%, Canvas));
  --color-host-danger-bg: var(--color-background-danger, #fee2e2);
  --color-host-danger-text: var(--color-text-danger, #991b1b);
  --radius-host: var(--border-radius-md, 8px);
  --radius-host-lg: var(--border-radius-lg, 12px);
  --font-host: var(--font-sans, system-ui, sans-serif);
  --font-host-mono: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
  --shadow-host: var(--shadow-md, none);
}

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

<SafeArea className="bg-host-bg p-5 font-host 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.

Accessible Focus Indicators With Ring Variables

MCP App hosts provide --color-ring-* variables for styling focus indicators. These are separate from border colors because focus rings need to stand out in both light and dark themes, and hosts pick colors that meet contrast requirements against their own backgrounds.

<button
  style={{
    padding: '0.5rem 1rem',
    borderRadius: 'var(--border-radius-md, 8px)',
    background: 'var(--color-background-secondary, Canvas)',
    color: 'var(--color-text-primary, CanvasText)',
    border: '1px solid var(--color-border-primary, color-mix(in srgb, CanvasText 16%, Canvas))',
    outline: 'none',
  }}
  onFocus={(e) => {
    e.currentTarget.style.boxShadow = '0 0 0 2px var(--color-ring-primary, Highlight)';
  }}
  onBlur={(e) => {
    e.currentTarget.style.boxShadow = 'none';
  }}
>
  Submit
</button>

Or with Tailwind’s ring-* utilities mapped to the host ring variables. Using these variables instead of hardcoded ring colors means your focus indicators pass accessibility contrast checks in every host and theme combination.

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, system-ui, sans-serif)',
        background: 'var(--color-background-primary, Canvas)',
        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
  • usePlatform() or useViewport() when layout only depends on device or size
  • useHostContext() when you need locale, platform, safe area insets, viewport dimensions, or the full styles object

Mobile Styling Considerations

On mobile, hosts can render MCP Apps with different constraints than desktop. Claude’s current MCP App design guidelines say mobile apps run in a native WebView, must respect safe areas, and should be designed from 320pt wide up to fullscreen. The display modes are still negotiated with host context, so your app should declare what it supports and adapt to what the host returns.

First, tap targets. Claude’s mobile guidance points to a 44 x 44pt minimum for touch targets. If your buttons or links are smaller than that, they’ll be hard to tap on mobile. The host CSS variables don’t enforce this, so you need to check it yourself.

Second, safe area insets. Mobile devices have notches, home indicators, native navigation bars, and chat composers that can cut into your layout. useHostContext provides safeAreaInsets so you can account for these, and SafeArea handles them automatically when you use it as your root element.

Third, minimum width. Design your components to work at 320pt wide. Claude’s MCP App design guidelines recommend this as the floor for mobile rendering.

const ctx = useHostContext();
const isMobile = ctx?.platform === 'mobile';

return (
  <SafeArea
    style={{
      fontFamily: 'var(--font-sans, system-ui, sans-serif)',
      padding: isMobile ? '0.75rem' : '1.25rem',
    }}
  >
    <button
      style={{
        minHeight: '44px',
        minWidth: '44px',
        padding: '0.75rem 1rem',
        borderRadius: 'var(--border-radius-md, 8px)',
        background: 'var(--color-background-secondary, Canvas)',
        color: 'var(--color-text-primary, CanvasText)',
      }}
    >
      Tap-friendly
    </button>
  </SafeArea>
);

The CSS variables themselves work the same on mobile. A component styled with --color-background-primary and --font-sans can look correct on both mobile and desktop because the host provides values for each environment. Your layout still needs to handle narrow widths, touch input, safe areas, and display-mode changes.

Presentation Metadata Affects Styling Too

Some styling decisions live in resource metadata, not CSS. _meta.ui.prefersBorder tells a host whether your component prefers a bordered container when the host supports that hint. This matters because a borderless inline component needs its own spacing and safe-area handling, while a bordered component may already sit inside a host card.

For production ChatGPT apps, current OpenAI guidance also treats _meta.ui.domain and _meta.ui.csp as part of the UI contract. Declare the dedicated component origin, the API origins your UI can call, and the asset origins it can load. A component that looks perfect locally can still fail in a published host if images, fonts, scripts, or API calls are blocked by CSP.

The portable path is:

  1. Use the MCP Apps standard metadata first: _meta.ui.resourceUri, _meta.ui.prefersBorder, _meta.ui.csp, and _meta.ui.domain.
  2. Add ChatGPT compatibility aliases only when you need them, such as _meta["openai/widgetCSP"] for redirect_domains.
  3. Feature-detect optional bridge APIs instead of branching on “ChatGPT” or “Claude”.

The resource metadata guide covers these fields in more detail.

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 tests, pass theme and display mode options to inspector.renderTool. In a normal sunpeak test setup, Playwright projects run the same test against ChatGPT-style and Claude-style host runtimes, so you do not need to loop over host names in the test body.

import { test, expect } from 'sunpeak/test';

const themes = ['light', 'dark'] as const;
const displayModes = ['inline', 'fullscreen'] as const;

for (const displayMode of displayModes) {
  for (const theme of themes) {
    test(`status card renders in ${displayMode} ${theme}`, async ({ inspector }) => {
      const result = await inspector.renderTool('show-status', undefined, {
        theme,
        displayMode,
      });

      const app = result.app();
      await expect(app.locator('text=Operational')).toBeVisible();
      await expect(app.locator('[data-testid="status-card"]')).toBeVisible();
    });
  }
}

Then add a simulation state that behaves like a minimal host style set. One simple way is to add a test-only resource variant or mock host context in your resource test harness where style variables are absent, then assert that the UI remains readable because the CSS fallbacks kick in:

test('status card stays readable without host color tokens', async ({ inspector }) => {
  const result = await inspector.renderTool('show-status-minimal-host');

  const app = result.app();
  await expect(app.locator('text=Operational')).toBeVisible();
  await expect(app.locator('[data-testid="status-card"]')).toHaveCSS('color', /rgb|oklch|color/);
});

The first loop covers both themes and the display modes you support. The host project matrix covers ChatGPT and Claude. The minimal-token fixture catches missing fallbacks. The complete testing guide has the full multi-host matrix setup, and the cross-host testing guide covers what to check for when CSS variable values differ between ChatGPT and Claude.

For visual regression testing, use result.screenshot() to capture and compare screenshots across theme and host combinations. This catches subtle color or spacing bugs that functional tests miss, like a card border that disappears in dark mode because its color matches the background.

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

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, system-ui, sans-serif)',
        background: 'var(--color-background-primary, Canvas)',
        color: 'var(--color-text-primary, CanvasText)',
        padding: '1.25rem',
      }}
    >
      <div
        style={{
          maxWidth,
          margin: '0 auto',
          background: 'var(--color-background-secondary, Canvas)',
          border:
            '1px solid var(--color-border-primary, color-mix(in srgb, CanvasText 16%, Canvas))',
          borderRadius: 'var(--border-radius-lg, 12px)',
          padding: '1rem',
          boxShadow: 'var(--shadow-sm, none)',
        }}
      >
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'flex-start',
            gap: '0.5rem',
          }}
        >
          <h1
            style={{
              fontSize: 'var(--text-md, 1rem)',
              fontWeight: 'var(--font-weight-semibold, 600)',
              margin: 0,
            }}
          >
            {output.title}
          </h1>
          <span
            style={{
              fontSize: 'var(--text-xs, 0.75rem)',
              fontWeight: 'var(--font-weight-medium, 500)',
              padding: '0.25rem 0.5rem',
              borderRadius: 'var(--border-radius-sm, 6px)',
              background: `var(${statusColors[output.status]}, Canvas)`,
              color: `var(${statusText[output.status]}, CanvasText)`,
              whiteSpace: 'nowrap',
            }}
          >
            {output.status.replace('_', ' ')}
          </span>
        </div>

        <div
          style={{
            marginTop: '0.75rem',
            fontSize: 'var(--text-sm, 0.875rem)',
            color: 'var(--color-text-secondary, color-mix(in srgb, CanvasText 68%, Canvas))',
            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 structural colors. This component has a good default path in dark ChatGPT, light ChatGPT, dark Claude, and light Claude, and it still stays readable when a future host provides only part of the style token set.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

How do I implement dark mode in an MCP App?

Use host CSS variables for the default path and use the useTheme hook only when component behavior needs to branch on light or dark mode. CSS variables such as --color-background-primary and --color-text-primary adapt when the host changes theme, so one style rule can work in both modes. Add fallback values because hosts may provide only part of the token set.

What CSS variables does the MCP App host provide?

MCP App hosts can provide color tokens for backgrounds, text, borders, semantic states, and focus rings; typography tokens such as --font-sans and --font-mono; border radius tokens; border width tokens; and shadow tokens. All variables are optional, so production components should use CSS fallbacks like var(--color-background-primary, Canvas) and test against hosts that provide different token subsets.

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, safe area insets, and host styles.

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

You should not branch on the host name for basic theme styling. Start with MCP Apps host context and CSS variables, then feature-detect host-specific extensions only when you need them. ChatGPT, Claude, and other compatible hosts can expose different token values and capabilities, so the right test matrix is host by theme by display mode, not a set of hardcoded product checks.

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

Apply font-family: var(--font-sans, system-ui, sans-serif) to your component root or body. The host can provide font-face CSS in McpUiHostContext.styles.css.fonts. In sunpeak React components, useHostContext applies host-provided styles and fonts to the iframe document; SafeArea handles safe area padding and viewport constraints. For code or tabular values, use var(--font-mono, ui-monospace, SFMono-Regular, monospace).

How do I test MCP App dark mode locally?

Use pnpm dev to start the local sunpeak inspector at localhost:3000. Toggle the theme in the sidebar to switch between light and dark mode. For automated tests, pass theme: "dark" or theme: "light" to inspector.renderTool, then repeat the same assertions across host and display mode options. These tests run locally and in CI without paid host 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, availableDisplayModes, locale, timeZone, platform, deviceCapabilities, safeAreaInsets, containerDimensions, and host styles. Use useTheme, useDisplayMode, usePlatform, useViewport, and other convenience hooks for one value. Use useHostContext when layout depends on several fields or when you need to inspect the complete host context during debugging.

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

Use host CSS variables with fallbacks throughout your component instead of hardcoded hex colors. Wrap the root in SafeArea from sunpeak so the component respects safe areas and viewport constraints. Use var(--font-sans, system-ui, sans-serif) for text, keep tool results useful without UI, and test the same component in ChatGPT-style and Claude-style host simulations before shipping.