Skip to main content
How to make your widget adapt to ChatGPT’s theme, locale, display mode, and device.

Available Hooks

HookWhat it provides
useLayoutTheme, safe areas, max height
useUserLocale, user agent, device capabilities
useDisplayModeCurrent display mode, mode switching
useRequestModalOpen widget in modal
useOpenExternalOpen external URLs

Theme Adaptation with useLayout

Match ChatGPT’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:
ModeDescription
pipPicture-in-picture (small floating window)
inlineInline with the conversation
fullscreenFull screen takeover
modalModal 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>
Open a modal version of your widget:
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
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>
  );
}