How to make your widget adapt to the host’s theme, locale, display mode, and device.
Available Hooks
| Hook | What it provides |
|---|
useLayout | Theme, safe areas, max height |
useUser | Locale, user agent, device capabilities |
useDisplayMode | Current display mode, mode switching |
useRequestModal | Open widget in modal |
useOpenExternal | Open external URLs |
MCP Apps Runtime: useRequestModal is polyfilled in MCP Apps. Modals render inside the widget iframe rather than being portaled to the host. See useRequestModal API for details.
Theme Adaptation with useLayout
Match the host’s light/dark theme:
import { useLayout } from "skybridge/web";
function ThemedWidget() {
const { theme } = useLayout();
const isDark = theme === "dark";
return (
<div style={{
backgroundColor: isDark ? "#1a1a1a" : "#ffffff",
color: isDark ? "#ffffff" : "#000000",
padding: "16px",
}}>
<h1>Hello</h1>
</div>
);
}
With CSS Variables
function ThemedWidget() {
const { theme } = useLayout();
return (
<div
className="widget"
data-theme={theme}
style={{
"--bg": theme === "dark" ? "#1a1a1a" : "#ffffff",
"--text": theme === "dark" ? "#ffffff" : "#000000",
} as React.CSSProperties}
>
{/* Content */}
</div>
);
}
.widget {
background: var(--bg);
color: var(--text);
}
Safe Areas
Handle device notches and navigation bars:
const { safeArea } = useLayout();
<div style={{
paddingTop: safeArea.top,
paddingBottom: safeArea.bottom,
paddingLeft: safeArea.left,
paddingRight: safeArea.right,
}}>
{/* Content */}
</div>
Max Height
Respect the container’s max height:
const { maxHeight } = useLayout();
<div style={{
maxHeight: maxHeight ?? "100vh",
overflow: "auto",
}}>
{/* Scrollable content */}
</div>
User Info with useUser
Access locale and device capabilities:
Locale provided by OpenAIThe locale value comes from OpenAI’s host environment, not directly from the user’s browser. It may not always match the user’s actual browser locale settings.
import { useUser } from "skybridge/web";
function LocalizedWidget() {
const { locale, userAgent } = useUser();
// Format currency based on locale
const formatPrice = (amount: number) => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
}).format(amount);
};
// Check device capabilities
const isMobile = userAgent.device.type === "mobile";
const hasHover = userAgent.capabilities.hover;
return (
<div>
<p>Price: {formatPrice(99.99)}</p>
{isMobile ? (
<button className="large-tap-target">Buy Now</button>
) : (
<button className={hasHover ? "hoverable" : ""}>Buy Now</button>
)}
</div>
);
}
Available User Agent Properties
const { userAgent } = useUser();
userAgent.device.type; // "mobile" | "tablet" | "desktop"
userAgent.capabilities.hover; // true if device has hover capability
userAgent.capabilities.touch; // true if device has touch capability
Display Mode with useDisplayMode
Widgets can render in different display modes:
| Mode | Description |
|---|
pip | Picture-in-picture (small floating window) |
inline | Inline with the conversation |
fullscreen | Full screen takeover |
modal | Modal overlay |
import { useDisplayMode } from "skybridge/web";
function AdaptiveWidget() {
const { displayMode, setDisplayMode } = useDisplayMode();
// Render differently based on mode
if (displayMode === "pip") {
return <CompactView />;
}
return (
<div>
<FullView />
<button onClick={() => setDisplayMode("fullscreen")}>
Go Fullscreen
</button>
</div>
);
}
Requesting Mode Change
const { setDisplayMode } = useDisplayMode();
// Go fullscreen for immersive content
<button onClick={() => setDisplayMode("fullscreen")}>
View Full Map
</button>
// Back to inline
<button onClick={() => setDisplayMode("inline")}>
Close Map
</button>
Modal Requests with useRequestModal
Modal Display Mode is not accessible via useDisplayMode, it can only be opened via useRequestModal.
import { useRequestModal } from "skybridge/web";
function ProductCard({ product }) {
const { open } = useRequestModal();
const showDetails = () => {
open({
title: product.name,
params: { productId: product.id },
});
};
return (
<div onClick={showDetails}>
<img src={product.image} alt={product.name} />
<p>{product.name}</p>
</div>
);
}
// In your modal component, access params via useToolInfo or similar
MCP Apps Runtime: useRequestModal is polyfilled in MCP Apps. Modals render inside the widget iframe rather than being portaled to the host. See useRequestModal API for details.
External Links with useOpenExternal
Open URLs outside the widget:
import { useOpenExternal } from "skybridge/web";
function LinkWidget() {
const openExternal = useOpenExternal();
const openDocs = () => {
openExternal({ url: "https://docs.example.com" });
};
const openApp = () => {
// Open mobile app if available
openExternal({ url: "myapp://deep-link" });
};
return (
<div>
<button onClick={openDocs}>View Documentation</button>
<button onClick={openApp}>Open in App</button>
</div>
);
}
External links open in a new tab/window. Some URLs may be blocked by the iframe’s CSP policy.
Responsive Design Pattern
Combine hooks for a fully adaptive widget:
function ResponsiveWidget() {
const { theme, safeArea, maxHeight } = useLayout();
const { locale, userAgent } = useUser();
const { displayMode, setDisplayMode } = useDisplayMode();
const isMobile = userAgent.device.type === "mobile";
const isDark = theme === "dark";
return (
<div
style={{
backgroundColor: isDark ? "#1a1a1a" : "#ffffff",
color: isDark ? "#ffffff" : "#000000",
paddingTop: safeArea.top,
paddingBottom: safeArea.bottom,
maxHeight: maxHeight ?? "100vh",
overflow: "auto",
}}
>
{displayMode === "pip" ? (
<MiniView onExpand={() => setDisplayMode("inline")} />
) : (
<FullView
isMobile={isMobile}
locale={locale}
onMinimize={() => setDisplayMode("pip")}
/>
)}
</div>
);
}