Progressive Disclosure for ChatGPT Apps: UI Design Guide

Progressive disclosure is the cornerstone of exceptional ChatGPT app design. By revealing information gradually—showing users only what they need, when they need it—you create interfaces that are both powerful and approachable. This comprehensive guide demonstrates how to implement layered complexity, drill-down patterns, expandable content, tooltips, and guided discovery systems that comply with OpenAI's strict widget runtime requirements.

Table of Contents

  1. Why Progressive Disclosure Matters for ChatGPT Apps
  2. Layer Manager: Controlling Information Hierarchy
  3. Drill-Down Handler: Deep Navigation Without Complexity
  4. Expandable Content System
  5. Tooltip System for Contextual Help
  6. Guided Discovery Engine
  7. OpenAI Compliance Requirements
  8. Performance Optimization
  9. Best Practices and Anti-Patterns

Why Progressive Disclosure Matters for ChatGPT Apps {#why-progressive-disclosure-matters}

ChatGPT apps face unique constraints that make progressive disclosure essential:

  • Token Limits: Your structuredContent must stay well under 4k tokens for optimal performance
  • No Nested Scrolling: OpenAI prohibits scrolling within inline widgets, requiring careful information layering
  • Maximum 2 CTAs: Each inline card can have at most two primary actions
  • Cognitive Load: Users interact with ChatGPT apps within conversation flow—complex UIs break that rhythm

Progressive disclosure solves these challenges by:

  1. Reducing Initial Payload: Show essentials first, load details on demand
  2. Maintaining Conversation Flow: Users stay focused on their task without overwhelming options
  3. Improving Discoverability: Guided patterns help users find advanced features naturally
  4. Enhancing Performance: Lazy-loading content keeps widgets responsive

Learn more about ChatGPT app builder best practices and how MakeAIHQ automates progressive disclosure patterns.

Layer Manager: Controlling Information Hierarchy {#layer-manager}

The layer manager orchestrates multiple information layers within a single widget, allowing users to drill deeper without leaving the chat context.

Implementation

/**
 * Layer Manager for Progressive Disclosure
 * Manages hierarchical information layers in ChatGPT widgets
 * Complies with OpenAI's inline widget constraints
 */
class LayerManager {
  constructor(initialLayer = 'overview') {
    this.currentLayer = initialLayer;
    this.layerStack = [initialLayer];
    this.layerData = new Map();
    this.transitionCallbacks = new Map();
    this.maxStackDepth = 3; // Prevent deep nesting (UX best practice)
  }

  /**
   * Register a layer with its content generator
   * @param {string} layerId - Unique layer identifier
   * @param {Function} contentFn - Function returning layer content
   * @param {Object} options - Layer configuration
   */
  registerLayer(layerId, contentFn, options = {}) {
    this.layerData.set(layerId, {
      contentFn,
      title: options.title || layerId,
      showBackButton: options.showBackButton !== false,
      maxTokens: options.maxTokens || 1000,
      preload: options.preload || false
    });

    // Preload layer if specified
    if (options.preload) {
      this.preloadLayer(layerId);
    }
  }

  /**
   * Navigate to a specific layer
   * @param {string} layerId - Target layer ID
   * @param {Object} context - Context data for layer
   */
  navigateToLayer(layerId, context = {}) {
    if (!this.layerData.has(layerId)) {
      console.error(`Layer ${layerId} not registered`);
      return null;
    }

    // Prevent excessive nesting
    if (this.layerStack.length >= this.maxStackDepth) {
      console.warn('Maximum layer depth reached');
      return null;
    }

    // Execute pre-transition callback
    const callback = this.transitionCallbacks.get(this.currentLayer);
    if (callback) callback('exit', this.currentLayer, layerId);

    // Update stack and current layer
    this.layerStack.push(layerId);
    this.currentLayer = layerId;

    // Generate and return layer content
    const layerConfig = this.layerData.get(layerId);
    const content = layerConfig.contentFn(context);

    // Execute post-transition callback
    const enterCallback = this.transitionCallbacks.get(layerId);
    if (enterCallback) enterCallback('enter', layerId, context);

    // Update widget state
    if (window.openai?.setWidgetState) {
      window.openai.setWidgetState({
        currentLayer: layerId,
        layerStack: this.layerStack,
        context
      });
    }

    return this.renderLayer(layerId, content, layerConfig);
  }

  /**
   * Navigate back to previous layer
   */
  goBack() {
    if (this.layerStack.length <= 1) {
      console.warn('Already at root layer');
      return null;
    }

    this.layerStack.pop();
    const previousLayer = this.layerStack[this.layerStack.length - 1];
    this.currentLayer = previousLayer;

    const layerConfig = this.layerData.get(previousLayer);
    const content = layerConfig.contentFn({});

    return this.renderLayer(previousLayer, content, layerConfig);
  }

  /**
   * Render layer with navigation controls
   */
  renderLayer(layerId, content, config) {
    const backButton = config.showBackButton && this.layerStack.length > 1
      ? `<button onclick="layerManager.goBack()" class="back-button">← Back</button>`
      : '';

    return `
      <div class="layer-container" data-layer="${layerId}">
        <div class="layer-header">
          ${backButton}
          <h2>${config.title}</h2>
        </div>
        <div class="layer-content">
          ${content}
        </div>
      </div>
    `;
  }

  /**
   * Preload layer content for faster transitions
   */
  async preloadLayer(layerId) {
    const config = this.layerData.get(layerId);
    if (config && config.contentFn) {
      // Cache content for instant display
      config.cachedContent = config.contentFn({});
    }
  }

  /**
   * Register transition callbacks for analytics/animations
   */
  onTransition(layerId, callback) {
    this.transitionCallbacks.set(layerId, callback);
  }

  /**
   * Get current layer depth (useful for breadcrumbs)
   */
  getDepth() {
    return this.layerStack.length;
  }

  /**
   * Reset to initial layer
   */
  reset() {
    this.layerStack = [this.layerStack[0]];
    this.currentLayer = this.layerStack[0];
  }
}

// Global instance
const layerManager = new LayerManager('overview');

// Example: Register layers for a fitness app
layerManager.registerLayer('overview', () => `
  <div class="overview">
    <p>Select a category to explore classes:</p>
    <button onclick="layerManager.navigateToLayer('yoga')">Yoga Classes</button>
    <button onclick="layerManager.navigateToLayer('strength')">Strength Training</button>
  </div>
`, { title: 'Class Overview' });

layerManager.registerLayer('yoga', (context) => `
  <div class="yoga-classes">
    <ul>
      <li onclick="layerManager.navigateToLayer('class-detail', {id: 101})">Morning Flow - 60min</li>
      <li onclick="layerManager.navigateToLayer('class-detail', {id: 102})">Power Yoga - 45min</li>
    </ul>
  </div>
`, { title: 'Yoga Classes' });

Key Features

  1. Stack-Based Navigation: Maintains navigation history for back button functionality
  2. Depth Limiting: Prevents excessive nesting (violates OpenAI's "no deep navigation" rule)
  3. State Synchronization: Uses window.openai.setWidgetState() for persistence
  4. Preloading: Caches layer content for instant transitions
  5. Callback Hooks: Enables analytics tracking and animations

Explore our AI conversational editor to generate layer manager code automatically.

Drill-Down Handler: Deep Navigation Without Complexity {#drill-down-handler}

Drill-down patterns allow users to explore hierarchical data (categories → items → details) without violating OpenAI's inline widget constraints.

Implementation

/**
 * Drill-Down Handler for Hierarchical Data
 * Enables deep navigation within ChatGPT widget constraints
 */
class DrillDownHandler {
  constructor(dataSource, config = {}) {
    this.dataSource = dataSource; // Function or object
    this.breadcrumbs = [];
    this.currentPath = [];
    this.maxDepth = config.maxDepth || 3;
    this.itemsPerPage = config.itemsPerPage || 5;
    this.renderCallback = config.renderCallback || this.defaultRenderer.bind(this);
  }

  /**
   * Drill down into a specific item
   * @param {string} itemId - Item identifier
   * @param {Object} metadata - Additional context
   */
  async drillDown(itemId, metadata = {}) {
    if (this.currentPath.length >= this.maxDepth) {
      this.showToast('Maximum drill-down depth reached', 'warning');
      return;
    }

    // Fetch item data
    const itemData = await this.fetchItemData(itemId, metadata);
    if (!itemData) {
      this.showToast('Item not found', 'error');
      return;
    }

    // Update path and breadcrumbs
    this.currentPath.push({ id: itemId, ...metadata });
    this.breadcrumbs.push({
      label: itemData.title || itemId,
      id: itemId
    });

    // Render new view
    this.render(itemData);

    // Update widget state
    if (window.openai?.setWidgetState) {
      window.openai.setWidgetState({
        drillDownPath: this.currentPath,
        breadcrumbs: this.breadcrumbs
      });
    }
  }

  /**
   * Navigate back one level
   */
  drillUp() {
    if (this.currentPath.length === 0) return;

    this.currentPath.pop();
    this.breadcrumbs.pop();

    // Re-render parent level
    if (this.currentPath.length > 0) {
      const parentId = this.currentPath[this.currentPath.length - 1].id;
      this.fetchItemData(parentId).then(data => this.render(data));
    } else {
      this.renderRoot();
    }
  }

  /**
   * Jump to specific breadcrumb level
   * @param {number} index - Breadcrumb index
   */
  jumpTo(index) {
    if (index < 0 || index >= this.breadcrumbs.length) return;

    this.currentPath = this.currentPath.slice(0, index + 1);
    this.breadcrumbs = this.breadcrumbs.slice(0, index + 1);

    const targetId = this.currentPath[index].id;
    this.fetchItemData(targetId).then(data => this.render(data));
  }

  /**
   * Fetch item data from data source
   */
  async fetchItemData(itemId, metadata = {}) {
    if (typeof this.dataSource === 'function') {
      return await this.dataSource(itemId, metadata);
    } else if (typeof this.dataSource === 'object') {
      return this.dataSource[itemId];
    }
    return null;
  }

  /**
   * Render current view with breadcrumbs
   */
  render(data) {
    const breadcrumbsHtml = this.renderBreadcrumbs();
    const contentHtml = this.renderCallback(data, this.currentPath.length);

    const container = document.getElementById('drill-down-container');
    if (container) {
      container.innerHTML = `
        <div class="drill-down-view">
          ${breadcrumbsHtml}
          <div class="drill-down-content">
            ${contentHtml}
          </div>
        </div>
      `;
    }
  }

  /**
   * Render breadcrumb navigation
   */
  renderBreadcrumbs() {
    if (this.breadcrumbs.length === 0) return '';

    const items = this.breadcrumbs.map((crumb, index) => {
      const isLast = index === this.breadcrumbs.length - 1;
      return isLast
        ? `<span class="breadcrumb-current">${crumb.label}</span>`
        : `<a href="#" onclick="drillDownHandler.jumpTo(${index}); return false;" class="breadcrumb-link">${crumb.label}</a>`;
    }).join(' › ');

    return `
      <nav class="breadcrumbs">
        <a href="#" onclick="drillDownHandler.reset(); return false;">Home</a> › ${items}
      </nav>
    `;
  }

  /**
   * Default content renderer
   */
  defaultRenderer(data, depth) {
    if (data.children && data.children.length > 0) {
      // Render list of child items
      const items = data.children.slice(0, this.itemsPerPage).map(child => `
        <li class="drill-item" onclick="drillDownHandler.drillDown('${child.id}')">
          <strong>${child.title}</strong>
          ${child.description ? `<p>${child.description}</p>` : ''}
        </li>
      `).join('');

      return `
        <h3>${data.title}</h3>
        <ul class="drill-list">${items}</ul>
        ${data.children.length > this.itemsPerPage ? '<p class="more-indicator">+ more items</p>' : ''}
      `;
    } else {
      // Leaf node - show details
      return `
        <h3>${data.title}</h3>
        <div class="detail-view">
          ${data.description ? `<p>${data.description}</p>` : ''}
          ${data.details ? `<div class="details">${data.details}</div>` : ''}
        </div>
      `;
    }
  }

  /**
   * Render root level
   */
  renderRoot() {
    this.currentPath = [];
    this.breadcrumbs = [];
    // Implement root view rendering
  }

  /**
   * Reset to root level
   */
  reset() {
    this.renderRoot();
    if (window.openai?.setWidgetState) {
      window.openai.setWidgetState({ drillDownPath: [], breadcrumbs: [] });
    }
  }

  /**
   * Show toast notification
   */
  showToast(message, type = 'info') {
    console.log(`[${type.toUpperCase()}] ${message}`);
    // Implement toast UI if needed
  }
}

// Example usage for restaurant menu
const menuData = {
  'menu': {
    title: 'Menu',
    children: [
      { id: 'appetizers', title: 'Appetizers', description: '12 items' },
      { id: 'entrees', title: 'Entrees', description: '18 items' }
    ]
  },
  'appetizers': {
    title: 'Appetizers',
    children: [
      { id: 'app-1', title: 'Spring Rolls', description: '$8.99' },
      { id: 'app-2', title: 'Bruschetta', description: '$7.99' }
    ]
  }
};

const drillDownHandler = new DrillDownHandler(menuData, {
  maxDepth: 3,
  itemsPerPage: 8
});

Key Features

  1. Breadcrumb Navigation: Shows current position in hierarchy
  2. Depth Limiting: Prevents violations of OpenAI's "no deep navigation" rule
  3. Pagination Support: Handles large datasets without overwhelming UI
  4. State Persistence: Preserves navigation across widget updates
  5. Flexible Data Source: Works with functions or static objects

Build drill-down interfaces with our instant app wizard—no coding required.

Expandable Content System {#expandable-content}

Expandable content (accordions, collapsible sections) reveals details on demand while keeping the initial view compact.

Implementation

/**
 * Expandable Content System
 * Implements accordions and collapsible sections for progressive disclosure
 */
class ExpandableContentManager {
  constructor(container, options = {}) {
    this.container = container;
    this.expandedSections = new Set();
    this.allowMultiple = options.allowMultiple !== false;
    this.animationDuration = options.animationDuration || 300;
    this.expandCallback = options.onExpand || null;
    this.collapseCallback = options.onCollapse || null;
  }

  /**
   * Create an expandable section
   * @param {string} id - Section identifier
   * @param {string} title - Section title
   * @param {string} content - Hidden content
   * @param {boolean} expanded - Initial state
   */
  createSection(id, title, content, expanded = false) {
    const section = document.createElement('div');
    section.className = 'expandable-section';
    section.dataset.sectionId = id;

    const header = document.createElement('div');
    header.className = 'expandable-header';
    header.innerHTML = `
      <span class="expand-icon">${expanded ? '▼' : '▶'}</span>
      <span class="section-title">${title}</span>
    `;
    header.onclick = () => this.toggle(id);

    const contentDiv = document.createElement('div');
    contentDiv.className = 'expandable-content';
    contentDiv.style.display = expanded ? 'block' : 'none';
    contentDiv.innerHTML = content;

    section.appendChild(header);
    section.appendChild(contentDiv);

    if (expanded) {
      this.expandedSections.add(id);
    }

    return section;
  }

  /**
   * Toggle section expand/collapse state
   */
  toggle(sectionId) {
    const isExpanded = this.expandedSections.has(sectionId);

    if (isExpanded) {
      this.collapse(sectionId);
    } else {
      if (!this.allowMultiple) {
        // Collapse all other sections (accordion behavior)
        this.expandedSections.forEach(id => {
          if (id !== sectionId) this.collapse(id);
        });
      }
      this.expand(sectionId);
    }

    // Update widget state
    if (window.openai?.setWidgetState) {
      window.openai.setWidgetState({
        expandedSections: Array.from(this.expandedSections)
      });
    }
  }

  /**
   * Expand a specific section
   */
  expand(sectionId) {
    const section = this.container.querySelector(`[data-section-id="${sectionId}"]`);
    if (!section) return;

    const content = section.querySelector('.expandable-content');
    const icon = section.querySelector('.expand-icon');

    content.style.display = 'block';
    icon.textContent = '▼';

    this.expandedSections.add(sectionId);

    if (this.expandCallback) {
      this.expandCallback(sectionId);
    }
  }

  /**
   * Collapse a specific section
   */
  collapse(sectionId) {
    const section = this.container.querySelector(`[data-section-id="${sectionId}"]`);
    if (!section) return;

    const content = section.querySelector('.expandable-content');
    const icon = section.querySelector('.expand-icon');

    content.style.display = 'none';
    icon.textContent = '▶';

    this.expandedSections.delete(sectionId);

    if (this.collapseCallback) {
      this.collapseCallback(sectionId);
    }
  }

  /**
   * Expand all sections
   */
  expandAll() {
    this.container.querySelectorAll('.expandable-section').forEach(section => {
      const id = section.dataset.sectionId;
      this.expand(id);
    });
  }

  /**
   * Collapse all sections
   */
  collapseAll() {
    this.expandedSections.forEach(id => this.collapse(id));
  }

  /**
   * Get current state
   */
  getState() {
    return {
      expandedSections: Array.from(this.expandedSections)
    };
  }

  /**
   * Restore state
   */
  restoreState(state) {
    if (state.expandedSections) {
      state.expandedSections.forEach(id => this.expand(id));
    }
  }
}

// Example usage: FAQ accordion
const container = document.getElementById('faq-container');
const faqManager = new ExpandableContentManager(container, {
  allowMultiple: false // Accordion behavior
});

const faqData = [
  {
    id: 'faq-1',
    question: 'How do I book a class?',
    answer: 'Click "Book Now" next to any class. You\'ll be prompted to select a time slot.'
  },
  {
    id: 'faq-2',
    question: 'What is your cancellation policy?',
    answer: 'Cancel up to 24 hours before class for a full refund. Within 24 hours, you\'ll receive 50% credit.'
  }
];

faqData.forEach(faq => {
  const section = faqManager.createSection(faq.id, faq.question, faq.answer);
  container.appendChild(section);
});

Key Features

  1. Accordion Mode: Optionally collapse other sections when one expands
  2. State Persistence: Remembers which sections are expanded
  3. Minimal DOM: Only renders visible content (performance optimization)
  4. Callback Hooks: Track user interactions for analytics
  5. Accessibility Ready: Can be enhanced with ARIA attributes

Learn how our template marketplace provides pre-built expandable content patterns.

Tooltip System for Contextual Help {#tooltip-system}

Tooltips provide just-in-time help without cluttering the interface.

Implementation

/**
 * Tooltip System for Contextual Help
 * Provides on-demand explanations without cluttering UI
 */
class TooltipManager {
  constructor(config = {}) {
    this.tooltips = new Map();
    this.activeTooltip = null;
    this.defaultPosition = config.defaultPosition || 'top';
    this.showDelay = config.showDelay || 500;
    this.hideDelay = config.hideDelay || 200;
    this.maxWidth = config.maxWidth || 250;
    this.initContainer();
  }

  /**
   * Initialize tooltip container
   */
  initContainer() {
    if (!document.getElementById('tooltip-container')) {
      const container = document.createElement('div');
      container.id = 'tooltip-container';
      container.className = 'tooltip-container';
      document.body.appendChild(container);
    }
  }

  /**
   * Register a tooltip
   * @param {string} elementId - Target element ID
   * @param {string} content - Tooltip content
   * @param {Object} options - Tooltip configuration
   */
  register(elementId, content, options = {}) {
    this.tooltips.set(elementId, {
      content,
      position: options.position || this.defaultPosition,
      maxWidth: options.maxWidth || this.maxWidth,
      showDelay: options.showDelay || this.showDelay
    });

    // Attach event listeners
    const element = document.getElementById(elementId);
    if (element) {
      element.addEventListener('mouseenter', () => this.show(elementId));
      element.addEventListener('mouseleave', () => this.hide());
      element.addEventListener('click', () => this.hide());
    }
  }

  /**
   * Show tooltip for element
   */
  show(elementId) {
    const config = this.tooltips.get(elementId);
    if (!config) return;

    // Clear any existing hide timeout
    if (this.hideTimeout) {
      clearTimeout(this.hideTimeout);
    }

    // Set show delay
    this.showTimeout = setTimeout(() => {
      const element = document.getElementById(elementId);
      if (!element) return;

      const tooltip = this.createTooltip(config.content, config.maxWidth);
      this.positionTooltip(tooltip, element, config.position);

      this.activeTooltip = { elementId, tooltip };
    }, config.showDelay);
  }

  /**
   * Hide active tooltip
   */
  hide() {
    if (this.showTimeout) {
      clearTimeout(this.showTimeout);
    }

    this.hideTimeout = setTimeout(() => {
      if (this.activeTooltip) {
        this.activeTooltip.tooltip.remove();
        this.activeTooltip = null;
      }
    }, this.hideDelay);
  }

  /**
   * Create tooltip element
   */
  createTooltip(content, maxWidth) {
    const tooltip = document.createElement('div');
    tooltip.className = 'tooltip';
    tooltip.style.maxWidth = `${maxWidth}px`;
    tooltip.innerHTML = content;

    const container = document.getElementById('tooltip-container');
    container.appendChild(tooltip);

    return tooltip;
  }

  /**
   * Position tooltip relative to target element
   */
  positionTooltip(tooltip, target, position) {
    const targetRect = target.getBoundingClientRect();
    const tooltipRect = tooltip.getBoundingClientRect();

    let top, left;

    switch (position) {
      case 'top':
        top = targetRect.top - tooltipRect.height - 8;
        left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
        break;
      case 'bottom':
        top = targetRect.bottom + 8;
        left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
        break;
      case 'left':
        top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
        left = targetRect.left - tooltipRect.width - 8;
        break;
      case 'right':
        top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
        left = targetRect.right + 8;
        break;
    }

    tooltip.style.top = `${top}px`;
    tooltip.style.left = `${left}px`;
  }
}

// Example usage
const tooltipManager = new TooltipManager({
  showDelay: 600,
  maxWidth: 200
});

tooltipManager.register('book-button', 'Click to reserve your spot in this class', {
  position: 'top'
});

tooltipManager.register('cancel-policy', 'Cancel up to 24 hours before class for full refund', {
  position: 'right'
});

Key Features

  1. Smart Positioning: Automatically positions tooltips to avoid viewport edges
  2. Configurable Delays: Prevents tooltip flicker on quick hovers
  3. Lightweight: Minimal DOM footprint
  4. Event Cleanup: Properly removes event listeners

Discover how MakeAIHQ's pricing plans include pre-built tooltip systems for all templates.

Guided Discovery Engine {#guided-discovery-engine}

The guided discovery engine walks users through complex features step-by-step, revealing capabilities progressively.

Implementation

/**
 * Guided Discovery Engine
 * Interactive tutorial system for progressive feature discovery
 */
class GuidedDiscoveryEngine {
  constructor(steps, config = {}) {
    this.steps = steps;
    this.currentStep = 0;
    this.completed = false;
    this.overlay = null;
    this.config = {
      showProgress: config.showProgress !== false,
      allowSkip: config.allowSkip !== false,
      persistProgress: config.persistProgress !== false,
      onComplete: config.onComplete || null
    };
  }

  /**
   * Start guided tour
   */
  start() {
    if (this.completed && !this.config.allowRestart) {
      console.log('Tour already completed');
      return;
    }

    this.currentStep = 0;
    this.createOverlay();
    this.showStep(0);
  }

  /**
   * Show specific step
   */
  showStep(stepIndex) {
    if (stepIndex < 0 || stepIndex >= this.steps.length) return;

    this.currentStep = stepIndex;
    const step = this.steps[stepIndex];

    // Highlight target element
    if (step.targetElement) {
      this.highlightElement(step.targetElement);
    }

    // Show instruction tooltip
    this.showInstruction(step);

    // Update widget state
    if (this.config.persistProgress && window.openai?.setWidgetState) {
      window.openai.setWidgetState({
        guideStep: stepIndex,
        guideCompleted: false
      });
    }
  }

  /**
   * Create overlay for highlighting
   */
  createOverlay() {
    this.overlay = document.createElement('div');
    this.overlay.className = 'guide-overlay';
    document.body.appendChild(this.overlay);
  }

  /**
   * Highlight target element
   */
  highlightElement(elementSelector) {
    const element = document.querySelector(elementSelector);
    if (!element) return;

    const rect = element.getBoundingClientRect();

    // Create highlight box
    const highlight = document.createElement('div');
    highlight.className = 'guide-highlight';
    highlight.style.top = `${rect.top - 4}px`;
    highlight.style.left = `${rect.left - 4}px`;
    highlight.style.width = `${rect.width + 8}px`;
    highlight.style.height = `${rect.height + 8}px`;

    this.overlay.appendChild(highlight);
  }

  /**
   * Show instruction tooltip
   */
  showInstruction(step) {
    const tooltip = document.createElement('div');
    tooltip.className = 'guide-tooltip';
    tooltip.innerHTML = `
      <h3>${step.title}</h3>
      <p>${step.description}</p>
      ${this.config.showProgress ? `<div class="progress">Step ${this.currentStep + 1} of ${this.steps.length}</div>` : ''}
      <div class="guide-actions">
        ${this.config.allowSkip ? '<button onclick="guideEngine.skip()">Skip Tour</button>' : ''}
        ${this.currentStep > 0 ? '<button onclick="guideEngine.previous()">Previous</button>' : ''}
        <button onclick="guideEngine.next()" class="primary">
          ${this.currentStep === this.steps.length - 1 ? 'Finish' : 'Next'}
        </button>
      </div>
    `;

    this.overlay.appendChild(tooltip);
  }

  /**
   * Move to next step
   */
  next() {
    if (this.currentStep < this.steps.length - 1) {
      this.clearOverlay();
      this.showStep(this.currentStep + 1);
    } else {
      this.complete();
    }
  }

  /**
   * Move to previous step
   */
  previous() {
    if (this.currentStep > 0) {
      this.clearOverlay();
      this.showStep(this.currentStep - 1);
    }
  }

  /**
   * Skip tour
   */
  skip() {
    this.end();
  }

  /**
   * Complete tour
   */
  complete() {
    this.completed = true;
    this.end();

    if (this.config.onComplete) {
      this.config.onComplete();
    }

    if (this.config.persistProgress && window.openai?.setWidgetState) {
      window.openai.setWidgetState({
        guideCompleted: true
      });
    }
  }

  /**
   * End tour
   */
  end() {
    if (this.overlay) {
      this.overlay.remove();
      this.overlay = null;
    }
  }

  /**
   * Clear overlay content
   */
  clearOverlay() {
    if (this.overlay) {
      this.overlay.innerHTML = '';
    }
  }
}

// Example usage: Onboarding for fitness app
const onboardingSteps = [
  {
    targetElement: '.class-list',
    title: 'Browse Classes',
    description: 'Here you\'ll find all available classes. Tap any class to see details.'
  },
  {
    targetElement: '.book-button',
    title: 'Book Your Spot',
    description: 'Click "Book Now" to reserve your spot. You can cancel anytime before the deadline.'
  },
  {
    targetElement: '.my-bookings',
    title: 'Manage Bookings',
    description: 'View all your upcoming classes here. You can cancel or reschedule easily.'
  }
];

const guideEngine = new GuidedDiscoveryEngine(onboardingSteps, {
  showProgress: true,
  allowSkip: true,
  persistProgress: true,
  onComplete: () => console.log('Onboarding completed!')
});

// Auto-start for first-time users
if (!window.openai?.getWidgetState()?.guideCompleted) {
  guideEngine.start();
}

Key Features

  1. Step-by-Step Guidance: Walks users through features sequentially
  2. Visual Highlighting: Draws attention to relevant UI elements
  3. Progress Persistence: Remembers where user left off
  4. Flexible Navigation: Supports forward, backward, and skip
  5. Completion Tracking: Prevents re-showing to experienced users

See how MakeAIHQ's AI generator creates custom onboarding flows for your industry.

OpenAI Compliance Requirements {#compliance-requirements}

Progressive disclosure implementations MUST comply with OpenAI's widget runtime constraints:

Critical Requirements

  1. No Nested Scrolling: Never create scrollable areas within inline widgets. Use layer navigation instead.
  2. Token Budget: Keep structuredContent under 4k tokens. Lazy-load expanded content via tool calls.
  3. Maximum 2 CTAs: Each layer/section can have at most 2 primary actions (buttons).
  4. System Fonts Only: Use SF Pro (iOS) or Roboto (Android)—NEVER custom fonts.
  5. WCAG AA Contrast: Ensure tooltips and overlays meet contrast requirements.
  6. State Management: Use window.openai.setWidgetState() for all persistent state.

Best Practices

  • Atomic Layers: Each layer should represent a single, focused task
  • Breadcrumb Depth: Limit to 3-4 levels maximum
  • Tooltip Brevity: Keep tooltip content under 50 words
  • Performance: Test with 3G throttling—guides should load instantly
  • Accessibility: Add ARIA labels to expandable sections and tooltips

Read our complete guide to ChatGPT app compliance for approval requirements.

Performance Optimization {#performance-optimization}

Progressive disclosure dramatically improves performance by deferring non-critical content:

Optimization Techniques

  1. Lazy Loading: Load layer content only when accessed
  2. Content Caching: Cache rendered layers for instant back-button navigation
  3. DOM Minimization: Keep collapsed sections empty until expanded
  4. Event Delegation: Use single event listener for all expandable sections
  5. Debouncing: Prevent tooltip flicker with show/hide delays

Measurement

// Track layer transition performance
layerManager.onTransition('products', (event, from, to) => {
  if (event === 'enter') {
    const startTime = performance.now();
    // Render layer...
    const endTime = performance.now();
    console.log(`Layer transition: ${endTime - startTime}ms`);
  }
});

Explore how our ROI calculator demonstrates progressive disclosure in action.

Best Practices and Anti-Patterns {#best-practices}

DO: Progressive Disclosure Best Practices

Start Simple: Show only essential information in the initial view ✅ Clear Affordances: Use visual cues (▶ icons, "Show more" links) to indicate expandable content ✅ Preserve Context: Keep breadcrumbs and back buttons visible during navigation ✅ Predictable Patterns: Use consistent expand/collapse behaviors throughout the app ✅ Test with Real Users: Validate that your information hierarchy matches user mental models

DON'T: Progressive Disclosure Anti-Patterns

Hide Critical Information: Don't bury essential CTAs or important warnings in collapsed sections ❌ Excessive Nesting: Avoid drill-downs deeper than 3-4 levels (users get lost) ❌ Ambiguous Labels: Section titles like "More Info" don't communicate value ❌ Forced Discovery: Don't require users to expand sections to understand basic features ❌ Inconsistent Behavior: Mixing accordions (single-expand) and collapsible sections (multi-expand) confuses users

Example: Fitness Class Booking

GOOD (Progressive Disclosure):

Overview: "12 yoga classes available this week"
  → Expand: List of classes with times
    → Drill-down: Class details + "Book Now" button

BAD (Information Overload):

Overview: Full schedule for all 50 classes, all times, all instructors, all details
(User is overwhelmed and abandons task)

Learn how MakeAIHQ's template marketplace provides industry-specific progressive disclosure patterns.

Internal Links

  • ChatGPT App Builder Features
  • AI Conversational Editor
  • Instant App Wizard
  • Template Marketplace
  • Pricing Plans
  • ROI Calculator
  • AI App Generator
  • ChatGPT App Approval Checklist
  • Information Architecture Best Practices
  • Widget Design Patterns

External Resources

  1. OpenAI Apps SDK Documentation - Official Apps SDK reference
  2. Nielsen Norman Group: Progressive Disclosure - UX research on progressive disclosure
  3. WCAG 2.1 Accessibility Guidelines - Ensure tooltips and expandable content are accessible

Ready to build ChatGPT apps with world-class progressive disclosure? Start your free trial with MakeAIHQ and deploy apps with optimized information architecture in 48 hours—no coding required.

Questions about implementing progressive disclosure in your ChatGPT app? Contact our team for expert guidance on layered UI design, drill-down patterns, and OpenAI compliance.