CodeGym /Courses /ChatGPT Apps /Controlling appearance: di...

Controlling appearance: displayMode, maxHeight, borders, theme, layout

ChatGPT Apps
Level 3 , Lesson 1
Available

1. Why manage appearance at all

Right now, your widget most likely looks like a “normal React component”: some div, a list of items, a couple of buttons. On the regular web that’s often enough. In ChatGPT there’s a nuance: your UI lives inside a chat where the user already has lots of visual context — messages, other Apps, voice UI, plus container size constraints.

Two things are important to keep in mind.

First, the widget has a display mode (displayMode): inline, fullscreen, sometimes PiP. The mode affects available area, scroll behaviour, and user expectations.

Second, the platform communicates height constraints (maxHeight) and the theme (theme) to the widget. If you ignore them and draw something the size of Notion inside a single message, the chat turns into a “black hole” where everything sinks into one huge iframe. OpenAI explicitly recommends keeping the UI concise and respecting system colours/typography.

A typical GiftGenius scenario illustrates how this works in practice. The user asks: “Pick a gift for a friend up to $50.” ChatGPT launches GiftGenius, which in inline mode shows compact gift cards and a couple of buttons. The user clicks “More details” — the widget requests fullscreen and shows filters, descriptions, reviews there. When the purchase is being processed, you can show a small PiP/modal with the status “Processing order…” without covering the whole chat.

Our goal in this lecture is to learn to:

  • understand the current displayMode and respond appropriately;
  • switch modes on demand (inline ↔ fullscreen, sometimes PiP);
  • respect maxHeight and avoid “double scroll”;
  • adapt styles to light/dark theme and screen width;
  • build a layout that feels “native” inside ChatGPT.

2. displayMode modes: inline, fullscreen, PiP

Let’s start with definitions. displayMode is the state of your widget’s container in ChatGPT. It comes from the platform (via window.openai.displayMode or a useDisplayMode hook) and can take values like "inline", "fullscreen", "pip".

Inline

Inline is the default mode. The widget is inserted directly into the message stream as another “block” between text replies. Width is limited by the chat column (on desktop ~700–800px, on mobile — the screen width), and height is dynamic but not infinite.

Inline is perfect for:

  • short, self-contained presentations: gift cards, a list of options, a search summary;
  • one or two actions: “Select,” “Cancel,” “Show more.”

For GiftGenius, this is the main mode: the user sends a request, and you show 3–5 gift cards with buttons without taking over the entire screen.

Fullscreen (Canvas)

Fullscreen (or canvas) is when your widget takes up most of the visible area. The chat does not disappear: the input line is still available, but the main focus is on your UI.

Turn on fullscreen when:

  • there are many input fields or a complex wizard (checkout, complex filters, settings);
  • you need to show large tables, maps, comparisons of dozens of items;
  • inline no longer fits and starts to look like a mini‑Excel 700px tall.

In GiftGenius, fullscreen is needed to provide full filters, sorting, detailed descriptions, possibly multiple tabs.

PiP / Modal

PiP (picture-in-picture) and modals are small “floating” windows on top of the main content. In current Apps SDK implementations, PiP is often implemented either as a special displayMode or as a modal window via requestModal().

They’re useful when:

  • you need to show the status of a long-running process (order processing, video rendering);
  • you need to ask something small without interrupting the main flow (quick confirmation);
  • you want to let the user “keep the widget in view” while continuing the chat.

In GiftGenius, this could be a small panel “Processing order… 30%” with a “Cancel” button.

A quick comparison

A table for visual perception:

Mode Where it lives Typical use cases Constraints
inline
in the message stream Lists, cards, one or two buttons Limited height, narrow width
fullscreen
above the chat / side pane Wizards, complex forms, tables Requires thoughtful layout and navigation
PiP / modal floating layer Status, mini‑forms, video Very little space; everything must be large and simple

It’s important not to treat fullscreen as the “real app” and inline as a “preview.” It’s the same App, just in different “poses.”

3. Hooks for working with modes: useDisplayMode, useRequestDisplayMode, useRequestModal

Now that we understand what inline/fullscreen/PiP are from a UX perspective, let’s see how to work with them from code via Apps SDK hooks.

Instead of reading window.openai.displayMode directly, we use a hook from the template that subscribes to changes and saves you from ritual SDK event handling. A typical interface looks like this:

// pseudo-types; check actual names in your template
type DisplayMode = 'inline' | 'fullscreen' | 'pip';

function useDisplayMode() {
  // returns the current mode
  return { displayMode: 'inline' as DisplayMode };
}

function useRequestDisplayMode() {
  // function to request a mode change
  return {
    requestDisplayMode: (mode: DisplayMode) => {
      /* calls window.openai.requestDisplayMode */
    },
  };
}

Let’s make a simple component that shows the current mode and gives an “Expand / Collapse” button:

import { useDisplayMode, useRequestDisplayMode } from '@/apps-sdk';

export function DisplayModeDebug() {
  const { displayMode } = useDisplayMode();
  const { requestDisplayMode } = useRequestDisplayMode();

  const toggle = () => {
    requestDisplayMode(displayMode === 'inline' ? 'fullscreen' : 'inline');
  };

  return (
    <div className="text-xs text-gray-500 flex gap-2 items-center">
      <span>Mode: {displayMode}</span>
      <button onClick={toggle} className="underline">
        Toggle
      </button>
    </div>
  );
}

In real Apps you usually hide such “debug” elements, but in Dev Mode this kind of component is great for feeling how the widget behaves when switching.

Inline vs fullscreen via separate subcomponents

A common mistake is trying to serve all modes with the same layout and stuffing JSX with if (displayMode === ...). It’s much easier on the brain to split the presentation:

import { useDisplayMode } from '@/apps-sdk';
import { GiftListInline } from './GiftListInline';
import { GiftListFullscreen } from './GiftListFullscreen';

export function GiftWidget() {
  const { displayMode } = useDisplayMode();

  if (displayMode === 'fullscreen') {
    return <GiftListFullscreen />;
  }

  return <GiftListInline />;
}

This way, the code reads as “if fullscreen — here’s the complex wizard; otherwise — compact inline.” And each subcomponent can be styled separately for its constraints. This approach is exactly what the module recommends: split modes into separate subcomponents instead of a huge if/else in one component.

Modals: useRequestModal

If the template provides a useRequestModal hook, its interface usually looks like:

const { requestModal } = useRequestModal();
// requestModal({ title }) or something along those lines.

Modals are somewhat similar to fullscreen but don’t replace it: fullscreen is for large scenarios; a modal is for one short step (confirm an action, enter a coupon code, etc.).

4. Size control: maxHeight, scrolling, and notifyIntrinsicHeight()

The second important axis is height. The platform tells the widget: “Here’s the maximum available height.” You can read this limit in window.openai.maxHeight or via a useMaxHeight hook.

Why you can’t just set “height: 5000px”

If you ignore maxHeight and set an enormous fixed height, ChatGPT will be forced to cut your content off. Or it will give the user a double scroll: the outer chat scroll and your widget’s inner scroll. That’s unpleasant UX: the user has to guess where to scroll to reach the button they need.

The right strategy is:

  1. Read the maxHeight limit.
  2. Build layout so that the main scroll remains with the chat (especially in inline).
  3. In fullscreen you can allow some internal scrolling, but do it carefully.

useMaxHeight and constraining the container

Let’s write a simple wrapper that sets a maximum height for the root container:

import { useMaxHeight } from '@/apps-sdk';

export function WidgetContainer(props: { children: React.ReactNode }) {
  const { maxHeight } = useMaxHeight(); // e.g., 600

  return (
    <div
      style={{ maxHeight }}
      className="overflow-y-auto p-4 bg-background border border-border rounded-xl"
    >
      {props.children}
    </div>
  );
}

Here we honestly constrain the height and enable vertical scroll inside the container, but within reason. In practice, it’s better to avoid a lot of internal scrolling in inline and, instead of huge lists, show part of the data with a “Show more” button or offer fullscreen.

Dynamic height and notifyIntrinsicHeight()

Another nuance: your content can change size over time. For example, first you show a spinner “Loading gifts…,” then a list of 10 cards, then the user collapses/expands filters. To make ChatGPT allocate the right space for the widget and not clip it, you need to report the new value when the height changes. That’s what notifyIntrinsicHeight() is for.

In the template, this is often wrapped in a hook like useAutoResize. You can implement it roughly like this:

import { useEffect, useRef } from 'react';
import { useNotifyIntrinsicHeight } from '@/apps-sdk';

export function useAutoResize() {
  const ref = useRef<HTMLDivElement | null>(null);
  const { notifyIntrinsicHeight } = useNotifyIntrinsicHeight();

  useEffect(() => {
    if (!ref.current) return;

    const observer = new ResizeObserver(entries => {
      for (const entry of entries) {
        notifyIntrinsicHeight(entry.contentRect.height);
      }
    });

    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [notifyIntrinsicHeight]);

  return ref;
}

And use it:

export function GiftListInline() {
  const containerRef = useAutoResize();

  return (
    <div ref={containerRef}>
      {/* your content */}
    </div>
  );
}

The idea is simple: when your root div changes height, you call the SDK API and ChatGPT adjusts the container. This pattern is directly recommended by experienced developers: an “auto-resizer wrapper” around all content.

A small diagram

Imagine it as a flowchart:

flowchart TD
    A[Widget content changed] --> B[ResizeObserver records the new height]
    B --> C["Call notifyIntrinsicHeight(newHeight)"]
    C --> D[ChatGPT increases/decreases the container]
    D --> E[The user sees a clean scroll without clipping]

That’s it for sizes and height: the widget shouldn’t spill out of the allotted space or give the user a double-scroll maze.

5. Theme (theme), colours, and borders: making the widget feel “native”

If displayMode and maxHeight determine how much space we have, then the theme (theme) and palette determine how that piece of UI looks inside the chat.

ChatGPT supports at least light and dark themes. The platform passes this to your widget via window.openai.theme and/or in _meta["openai/theme"], and the React template has a hook like useOpenAiGlobal("theme") or useTheme.

The main idea: your UI should adapt to the theme, not impose its own.

Getting the theme

Example of a simple hook:

import { useOpenAiGlobal } from '@/apps-sdk';

export function useThemeMode() {
  const theme = useOpenAiGlobal<'light' | 'dark'>('theme') ?? 'light';
  return { theme };
}

In a component:

export function ThemedCard(props: { children: React.ReactNode }) {
  const { theme } = useThemeMode();

  const className =
    theme === 'dark'
      ? 'bg-slate-900 text-slate-100 border-slate-700'
      : 'bg-white text-slate-900 border-slate-200';

  return (
    <div className={`rounded-xl border p-4 ${className}`}>
      {props.children}
    </div>
  );
}

In a real project you’ll likely use Tailwind with darkMode: 'class' and attach a dark class to the widget’s root container. But the essence is the same: the theme comes from the Apps SDK, not from your own world.

Colours, borders, and typography

OpenAI’s guidelines:

  • use system fonts and clean typography;
  • don’t aggressively override system colours;
  • the widget should be a “native” element of the chat, not a standalone landing page with neon gradients.

A good pattern for a GiftGenius container:

export function GiftCard(props: { title: string; price: string }) {
  return (
    <div className="rounded-xl border border-border bg-background p-3 flex flex-col gap-2">
      <div className="font-medium text-foreground">{props.title}</div>
      <div className="text-sm text-muted-foreground">{props.price}</div>
      <button className="self-start px-3 py-1 text-sm rounded-full bg-primary text-primary-foreground">
        Select
      </button>
    </div>
  );
}

This assumes that bg-background, border-border, text-foreground, bg-primary, etc. are CSS variables/utility classes tied to the ChatGPT theme. This approach is described in the recommendations: use theme-bound variables and classes instead of hardcoding colours.

6. Layout and responsiveness: desktop, mobile, PiP

The third axis is width and device. In simple terms, the widget’s appearance is defined by the mode (displayMode), available height (maxHeight), and available width (desktop/mobile/PiP).

In this section, we’ll deal with the third parameter. On desktop, the inline widget has one width; on mobile — another; in PiP there’s very little room. The Apps SDK passes signals like userAgent, safeArea, sometimes container size, which you can read via useOpenAiGlobal.

General principles

Several important principles below.

First, don’t rely on a fixed width. The user’s screen can be narrow (phone) or wide (large desktop). So it’s better to build layouts on flex/grid with auto-fit rather than a hard width: 400px.

Second, avoid horizontal scrolling. If your table or cards don’t fit, it’s better to switch to fullscreen or show a shortened version. A carousel with slides is also an option.

Third, keep in mind that PiP/modals are often very narrow, and you can’t put a big form there — it will literally be painful for the user to hit fields.

These points are explicitly emphasized in the docs: responsiveness, safeArea, the desktop vs mobile difference, and the risk of overloaded layouts.

Different layouts for inline and fullscreen

Back to GiftGenius. The gift list in inline and fullscreen can look very different. Let’s make two components.

Compact inline: up to 3 cards, one column on mobile and two on wider screens.

export function GiftListInline() {
  const gifts = useGiftData(); // hypothetical hook; read from toolOutput

  return (
    <WidgetContainer>
      <h2 className="text-base font-semibold mb-3">
        Gift selection
      </h2>

      <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
        {gifts.slice(0, 3).map(gift => (
          <GiftCard
            key={gift.id}
            title={gift.title}
            price={`${gift.price} $`}
          />
        ))}
      </div>

      {gifts.length > 3 && (
        <p className="mt-3 text-xs text-muted-foreground">
          Showing the first 3 options. Expand the widget to see everything.
        </p>
      )}
    </WidgetContainer>
  );
}

And the fullscreen version: grid, filters, more cards.

export function GiftListFullscreen() {
  const gifts = useGiftData();
  const [query, setQuery] = useState('');

  const filtered = gifts.filter(g =>
    g.title.toLowerCase().includes(query.toLowerCase()),
  );

  return (
    <div className="h-full flex flex-col gap-4 p-4">
      <header className="flex gap-2 items-center">
        <h1 className="text-lg font-semibold flex-1">
          Gifts for you
        </h1>
        <input
          value={query}
          onChange={e => setQuery(e.target.value)}
          placeholder="Filter by name"
          className="px-2 py-1 text-sm border rounded-md flex-1"
        />
      </header>

      <main className="flex-1 overflow-y-auto">
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
          {filtered.map(gift => (
            <GiftCard
              key={gift.id}
              title={gift.title}
              price={`${gift.price} $`}
            />
          ))}
        </div>
      </main>
    </div>
  );
}

Here we allow internal vertical scrolling for fullscreen content (overflow-y-auto on main), which is normal for fullscreen. The inline version, as the guides recommend, remains compact and “readable in 2 seconds.”

Schematic: behaviour by mode

To reinforce it, here’s a simple diagram:

stateDiagram-v2
    [*] --> Inline
    Inline: 3 cards, minimal text
    Inline --> Fullscreen: Click "Expand" / "Show all"
    Fullscreen: Grid, filters, lots of data
    Fullscreen --> Inline: Button "Close" / host action
    Fullscreen --> PiP: Long operation, show progress
    PiP: Small status panel
    PiP --> Inline: Operation finished, show the final message

This scenario closely follows the described UX patterns: inline as a teaser, fullscreen as the working tool, PiP as a process indicator.

7. Practice: two modes of the same widget

Time to consolidate this in code. As practice for this lecture, it makes sense to do two steps in the current training app.

Step 1. Inline widget with a card

Extend the current GiftGenius so that in inline mode the widget:

  • shows the heading “Gift selection”;
  • displays up to three gift cards from toolOutput;
  • shows a hint “Expand the widget to see everything” if there are more than three gifts;
  • neatly adjusts its height via useAutoResize and notifyIntrinsicHeight().

At the same time, styles should rely on the theme: use classes or variables tied to the theme, not hardcoded colours.

Step 2. Fullscreen version with a form

Then add a fullscreen presentation that:

  • shows a header + search by name;
  • renders all gifts in a grid;
  • allows vertical scrolling inside the main area;
  • provides a “Return to chat” button (which calls requestDisplayMode('inline')).

The composition might look like this:

export function GiftGeniusWidget() {
  const { displayMode } = useDisplayMode();

  return (
    <>
      <DisplayModeDebug />
      {displayMode === 'fullscreen' ? (
        <GiftListFullscreen />
      ) : (
        <GiftListInline />
      )}
    </>
  );
}

In ChatGPT Dev Mode you can manually switch the mode or request fullscreen programmatically on a click of the “Show all” button in the inline version (via useRequestDisplayMode). This exercise will cement the idea of how the same App can look and behave differently depending on displayMode.

8. Common mistakes when managing widget appearance

Before moving on, let’s capture a few common rakes related to displayMode, sizes, theme, and layout. If you avoid them from the start, life with the Apps SDK will be much nicer.

Mistake #1: Ignoring displayMode and trying to force everything to be fullscreen-like.
Sometimes developers draw one heavy layout (almost like a separate SPA) that barely fits inline. As a result, the user sees a miniature Notion with scrollbars and a million elements. The correct approach is to design different presentations for different modes and respect that inline is a compact, “one-screen” format.

Mistake #2: Huge fixed height and double scroll.
Setting height: 800px and forgetting about maxHeight is a quick path to your widget being either clipped or producing both internal and external scrollbars at the same time. The user starts “hunting” for the right scrollbar, which severely hurts UX. Instead, read maxHeight, constrain via max-height, and report height changes via notifyIntrinsicHeight().

Mistake #3: Ignoring the theme and trying to “repaint everything to your brand.”
If you set your own fonts, backgrounds, high-contrast gradients, and completely ignore ChatGPT’s light/dark theme, you break the platform’s visual unity. The guidelines clearly say: use system colours and fonts, and bring your brand in with subtle accents (button, icon, logo). Watch theme via a hook and adapt the palette.

Mistake #4: UI that’s too complex in PiP/modals.
Trying to cram a full form with many fields into a small PiP window is a dead end. Only very simple cases fit there: process progress, one or two buttons, a single input field. Everything else is a candidate for fullscreen.

Mistake #5: Hardcoding for 800px and not testing on mobile.
Hardcoding for 800px and assuming “it will somehow fit on the phone.” In reality, the ChatGPT mobile client has a very different width and behaviour, and PiP is even narrower. Don’t forget about userAgent/safeArea, use grid/flex without hard width, and at least once look at your widget in a narrow layout.

Mistake #6: Working directly with window.openai without hooks.
Formally you can write const mode = window.openai.displayMode, but then you’ll subscribe to events yourself, think about React updates, and catch bugs if the SDK changes something. Hooks (useDisplayMode, useMaxHeight, useOpenAiGlobal, useRequestDisplayMode) exist to hide this routine and keep code clean. It’s better to use them and live peacefully.

1
Task
ChatGPT Apps, level 3, lesson 1
Locked
Mode and theme badge + inline/fullscreen toggle
Mode and theme badge + inline/fullscreen toggle
1
Task
ChatGPT Apps, level 3, lesson 1
Locked
SafeAreaViewport — a container that respects maxHeight and safeArea
SafeAreaViewport — a container that respects maxHeight and safeArea
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION