ARCHITECTURE

Overview

Overlay system architecture

This document provides a deep dive into the technical architecture of the overlay system for contributors and advanced users.

System overview

Section titled System overview

The overlay system is built on several key architectural patterns:

┌─────────────────────────────────────────────────────────────┐
│                      Entry Points                            │
├───────────────┬──────────────┬───────────────┬──────────────┤
│ <sp-overlay>  │ <overlay-    │ Overlay.open()│ trigger()    │
│               │  trigger>    │               │ directive    │
└───────┬───────┴───────┬──────┴───────┬───────┴──────┬───────┘
        │               │              │              │
        └───────────────┴──────────────┴──────────────┘
                        │
        ┌───────────────▼──────────────────────────┐
        │         Overlay Class                     │
        │  ┌─────────────────────────────────────┐ │
        │  │ AbstractOverlay                     │ │
        │  │   + OverlayPopover / OverlayNoPopover│ │
        │  └─────────────────────────────────────┘ │
        └──────────┬────────────────────────────────┘
                   │
        ┌──────────▼────────────┬─────────────────────┐
        │                       │                     │
┌───────▼─────────┐   ┌────────▼────────┐   ┌───────▼────────┐
│ Interaction     │   │  Placement      │   │  Overlay       │
│ Controllers     │   │  Controller     │   │  Stack         │
├─────────────────┤   ├─────────────────┤   ├────────────────┤
│ - Click         │   │ Uses Floating   │   │ Global state   │
│ - Hover         │   │ UI for position │   │ Focus trapping │
│ - Longpress     │   │ and constraints │   │ ESC handling   │
└─────────────────┘   └─────────────────┘   └────────────────┘

Core components

Section titled Core components

AbstractOverlay

Section titled AbstractOverlay

The AbstractOverlay class provides the foundational interface and minimal implementation that all overlay implementations build upon.

Key responsibilities:

  • Defines property signatures and getters/setters
  • Provides lifecycle hooks (applyFocus, dispose)
  • Establishes the contract for reactive controllers
  • Minimal implementation allows mixins to add functionality

Design rationale: Using an abstract base class allows the mixin pattern to work effectively. The OverlayPopover and OverlayNoPopover mixins add browser-specific functionality while the Overlay class adds the complete implementation.

Overlay class

Section titled Overlay class

The Overlay class extends AbstractOverlay with a mixin (either OverlayPopover or OverlayNoPopover based on browser support) and implements the complete overlay functionality.

Key properties:

  • open: Boolean controlling visibility
  • type: Determines interaction model (modal, page, hint, auto, manual)
  • placement: Position relative to trigger
  • trigger: String reference to trigger element with interaction type
  • triggerElement: Direct element or VirtualTrigger reference
  • delayed: Enables warm-up/cool-down timing
  • receivesFocus: Controls focus behavior

Key methods:

  • bindEvents(): Sets up interaction controllers
  • manageDelay(): Handles delayed opening logic
  • handleBeforetoggle(): Prepares overlay state before visibility changes
  • handleTransitionEvents(): Tracks CSS transitions for sp-opened/sp-closed events

Mixin pattern: OverlayPopover and OverlayNoPopover

Section titled Mixin pattern: OverlayPopover and OverlayNoPopover

The overlay system uses mixins to handle browser-specific behaviors:

// Browser detection
const browserSupportsPopover = 'showPopover' in document.createElement('div');

// Apply appropriate mixin
let ComputedOverlayBase = OverlayPopover(AbstractOverlay);
if (!browserSupportsPopover) {
    ComputedOverlayBase = OverlayNoPopover(AbstractOverlay);
}

// Overlay extends the computed base
export class Overlay extends ComputedOverlayBase {
    // Implementation
}

OverlayPopover: Uses modern popover API for top-layer rendering

OverlayNoPopover: Uses <dialog> element and manual z-index management

This approach provides:

  • Transparent fallback for older browsers
  • Single codebase for all browsers
  • Progressive enhancement

Interaction controllers

Section titled Interaction controllers

Interaction controllers follow the Reactive Controller pattern and manage the relationship between trigger elements and overlays.

Base: InteractionController

Section titled Base: InteractionController

All controllers extend InteractionController which provides:

Core functionality:

  • open property: Manages overlay state
  • overlay property: Reference to associated overlay with automatic binding
  • isPersistent flag: Controls initialization timing
  • hostConnected() / hostDisconnected(): Lifecycle hooks

Lifecycle:

Constructor
    │
    ├──[if isPersistent]──> init()
    │                        └─> Bind trigger events
    │
    ├──[if overlay provided]─> set overlay
    │                           ├─> overlay.addController(this)
    │                           ├─> initOverlay()
    │                           └─> prepareDescription()
    │
    └──> Host element tracks controller

hostConnected()
    │
    └──[if !isPersistent]──> init()
                              └─> Bind trigger events

hostDisconnected()
    │
    └──[if !isPersistent]──> abort()
                              ├─> releaseDescription()
                              └─> abortController.abort()

ClickController

Section titled ClickController

Manages click interactions with toggle behavior.

Event handling:

init() {
    this.abortController = new AbortController();
    target.addEventListener('click', handleClick, { signal });
    target.addEventListener('pointerdown', handlePointerdown, { signal });
}

Toggle prevention logic:

  • On pointerdown: If overlay is open, set preventNextToggle = true
  • On click: Toggle overlay unless preventNextToggle is set
  • This prevents closing and immediately reopening when clicking the trigger

Use cases:

  • Dropdown menus
  • Modal dialogs
  • Expandable panels

HoverController

Section titled HoverController

Manages hover and focus interactions with delayed close behavior.

State tracking:

private hovering = false;           // Mouse over trigger or overlay
private targetFocused = false;      // Trigger has focus
private overlayFocused = false;     // Content within overlay has focus
private hoverTimeout?: ReturnType<typeof setTimeout>;

Event handling:

init() {
    // Bind to trigger
    target.addEventListener('keyup', handleKeyup, { signal });
    target.addEventListener('focusin', handleTargetFocusin, { signal });
    target.addEventListener('focusout', handleTargetFocusout, { signal });
    target.addEventListener('pointerenter', handleTargetPointerenter, { signal });
    target.addEventListener('pointerleave', handleTargetPointerleave, { signal });
}

initOverlay() {
    // Bind to overlay itself
    overlay.addEventListener('pointerenter', handleHostPointerenter, { signal });
    overlay.addEventListener('pointerleave', handleHostPointerleave, { signal });
    overlay.addEventListener('focusin', handleOverlayFocusin, { signal });
    overlay.addEventListener('focusout', handleOverlayFocusout', { signal });
}

Close delay logic:

  • When pointer or focus leaves, schedule close after 300ms
  • If pointer or focus returns within 300ms, cancel scheduled close
  • Allows smooth transition from trigger to overlay content

Accessibility features:

  • Adds aria-describedby linking trigger to tooltip content
  • Responds to ESC key to close and return focus
  • Handles :focus-visible to avoid showing on click interactions

Use cases:

  • Tooltips
  • Hover cards
  • Info popovers

LongpressController

Section titled LongpressController

Detects longpress gestures on trigger elements.

Timing:

  • Longpress threshold: 350ms
  • Touch movement threshold: 10px

Event handling:

init() {
    target.addEventListener('pointerdown', handlePointerdown, { signal });
    target.addEventListener('pointerup', handlePointerup, { signal });
    target.addEventListener('pointermove', handlePointermove, { signal });
    target.addEventListener('pointercancel', handlePointercancel, { signal });
}

State machine:

pointerdown
    │
    ├─> Start 350ms timer
    ├─> Record start position
    │
    ├─[pointermove > 10px]─> Cancel timer
    ├─[pointerup < 350ms]──> Cancel timer
    ├─[pointercancel]──────> Cancel timer
    │
    └─[timer expires]──────> Open overlay
                              + Set activelyOpening flag

Accessibility features:

  • Provides aria-describedby with longpress instructions
  • Descriptor text customizable via longpress-describedby-descriptor slot

Use cases:

  • Mobile context menus
  • Hold-to-reveal actions
  • Alternative interaction methods

Placement system

Section titled Placement system

The PlacementController manages overlay positioning using Floating UI.

Key concepts

Section titled Key concepts

Placement: Initial preferred position (top, bottom-start, etc.)

Fallback placements: Alternative positions when space is constrained

Middleware: Floating UI plugins that modify position:

  • offset: Adds spacing between trigger and overlay
  • flip: Switches to fallback placement when constrained
  • shift: Slides overlay along axis to stay in view
  • size: Adjusts overlay dimensions to fit viewport
  • arrow: Positions arrow element (if present)

Configuration

Section titled Configuration
const config = {
    placement: 'bottom-start',
    middleware: [
        offset(offsetValue),
        flip({
            fallbackPlacements: ['top-start', 'right', 'left'],
            padding: REQUIRED_DISTANCE_TO_EDGE, // 8px
        }),
        shift({ padding: REQUIRED_DISTANCE_TO_EDGE }),
        size({
            apply({ availableHeight }) {
                // Ensure minimum height
                const height = Math.max(availableHeight, MIN_OVERLAY_HEIGHT);
                overlay.style.maxHeight = `${height}px`;
            },
        }),
    ],
};

Auto-update

Section titled Auto-update

The controller uses Floating UI's autoUpdate to reposition when:

  • Trigger element moves or resizes
  • Overlay content changes dimensions
  • Viewport is resized or scrolled
  • Any ancestor element changes

Cleanup: The controller's cleanup() method stops auto-update when overlay closes.

Device pixel ratio rounding

Section titled Device pixel ratio rounding

Positions are rounded to device pixel ratio to prevent subpixel rendering issues:

function roundByDPR(num?: number): number {
    const dpr = window.devicePixelRatio || 1;
    return Math.round(num * dpr) / dpr;
}

Overlay stack

Section titled Overlay stack

The OverlayStack class manages all open overlays globally.

Responsibilities

Section titled Responsibilities

Track overlay order:

private overlays: Overlay[] = [];

add(overlay: Overlay): void {
    if (!this.overlays.includes(overlay)) {
        this.overlays.push(overlay);
    }
}

remove(overlay: Overlay): void {
    const index = this.overlays.indexOf(overlay);
    if (index > -1) {
        this.overlays.splice(index, 1);
    }
}

Manage focus trapping:

  • Modal and page overlays create focus traps
  • Focus traps prevent tabbing outside overlay
  • Nested overlays have nested focus traps

Handle ESC key:

document.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
        const topOverlay = this.overlays[this.overlays.length - 1];
        if (topOverlay?.type !== 'page') {
            topOverlay?.close();
            event.preventDefault();
            event.stopPropagation();
        }
    }
});

Coordinate overlays:

  • Prevent multiple modal overlays simultaneously
  • Manage light dismiss behavior
  • Coordinate delayed tooltips

Light dismiss

Section titled Light dismiss

"Light dismiss" means closing an overlay when interacting outside it. The stack manages this by:

  1. Listening for clicks at capture phase
  2. Checking if click target is within current overlay
  3. Closing overlay if click is outside (for auto type)
document.addEventListener(
    'click',
    (event) => {
        const topOverlay = this.overlays[this.overlays.length - 1];
        if (topOverlay?.type === 'auto') {
            const path = event.composedPath();
            if (!path.includes(topOverlay)) {
                topOverlay.close();
            }
        }
    },
    { capture: true }
);

State management

Section titled State management

Open/closed lifecycle

Section titled Open/closed lifecycle
Closed State
    │
    └─[open = true]──> Opening State
                        │
                        ├─> Dispatch 'slottable-request'
                        ├─> Add to overlay stack
                        ├─> Apply positioning
                        ├─> Show overlay (popover/dialog)
                        ├─> Start CSS transitions
                        │
                        └─[transitions end]──> Open State
                                                │
                                                └─> Dispatch 'sp-opened'

Open State
    │
    └─[open = false]──> Closing State
                         │
                         ├─> Start CSS transitions
                         │
                         └─[transitions end]──> Closed State
                                                 │
                                                 ├─> Hide overlay
                                                 ├─> Remove from stack
                                                 ├─> Dispatch 'sp-closed'
                                                 └─> Dispatch 'slottable-request'
                                                     with removeSlottableRequest

Transition tracking

Section titled Transition tracking

The overlay tracks CSS transitions on direct children to know when to dispatch sp-opened and sp-closed events:

guaranteedAllTransitionend(
    element,
    () => {
        // Trigger transition (e.g., add class)
    },
    () => {
        // Transition complete callback
    }
);

Guarantees:

  • Callback fires even if no transitions occur
  • Tracks multiple properties transitioning
  • Handles transitioncancel events
  • Uses multiple requestAnimationFrame calls to catch WebKit early firing

Event system

Section titled Event system

Custom events

Section titled Custom events

sp-opened: Dispatched when overlay is fully visible

type OverlayStateEvent = Event & {
    overlay: Overlay;
};

sp-closed: Dispatched when overlay is fully hidden

slottable-request: Requests content to be added or removed

type SlottableRequestEvent = CustomEvent & {
    data: {} | typeof removeSlottableRequest;
};

Event bubbling and composition

Section titled Event bubbling and composition

Overlays dispatch events that bubble and compose through shadow DOM:

this.dispatchEvent(
    new CustomEvent('sp-opened', {
        bubbles: true,
        composed: true,
        detail: { overlay: this },
    })
);

This allows parent components to listen for any nested overlay opening.

Close event

Section titled Close event

Overlays listen for a close event on themselves and their children:

<sp-button onclick="this.dispatchEvent(new Event('close', {bubbles: true}))">
    Close
</sp-button>

When received, the overlay closes itself. This provides a standard way for content to close its containing overlay.

Performance optimizations

Section titled Performance optimizations

Lazy initialization

Section titled Lazy initialization

Controllers can be non-persistent, delaying initialization until hostConnected():

new ClickController(target, {
    overlay,
    isPersistent: false, // Don't init until connected
});

Benefits:

  • Reduces initial setup cost
  • Allows garbage collection of unused controllers
  • Automatic cleanup on disconnect

Delayed tooltips

Section titled Delayed tooltips

The delayed attribute uses shared timers to coordinate tooltip opening:

class OverlayTimer {
    private warmupTimer?: ReturnType<typeof setTimeout>;
    private cooldownTimer?: ReturnType<typeof setTimeout>;
    private isWarmedUp = false;

    shouldDelay(): boolean {
        return !this.isWarmedUp;
    }

    recordOpen(): void {
        clearTimeout(this.cooldownTimer);
        if (!this.isWarmedUp) {
            this.isWarmedUp = true;
        }
    }

    recordClose(): void {
        clearTimeout(this.cooldownTimer);
        this.cooldownTimer = setTimeout(() => {
            this.isWarmedUp = false;
        }, 1000);
    }
}

Benefits:

  • First tooltip waits 1000ms
  • Subsequent tooltips open immediately
  • System cools down after 1000ms of no tooltips

Virtual triggers

Section titled Virtual triggers

VirtualTrigger provides positioning without a DOM element:

class VirtualTrigger {
    private rect: DOMRect;

    constructor(x: number, y: number) {
        this.updateBoundingClientRect(x, y);
    }

    updateBoundingClientRect(x: number, y: number): void {
        this.rect = new DOMRect(x, y, 0, 0);
    }

    getBoundingClientRect(): DOMRect {
        return this.rect;
    }
}

Use cases:

  • Context menus at cursor position
  • Drag-and-drop target previews
  • Touch gesture responses

Performance: No DOM queries or mutations required for positioning updates.

Browser compatibility

Section titled Browser compatibility

Popover API support

Section titled Popover API support

The system detects and adapts to popover API support:

const browserSupportsPopover = 'showPopover' in document.createElement('div');

With popover support:

  • Uses native top-layer rendering
  • Automatic z-index management
  • Better performance

Without popover support:

  • Falls back to <dialog> element
  • Manual z-index management
  • Additional CSS workarounds may be needed

Known issues

Section titled Known issues

WebKit clip bug: WebKit bug #160953

  • Affects position: fixed in containers with specific CSS
  • Workaround: Restructure DOM or adjust CSS

Focus trap limitations:

  • Some browsers have inconsistent focus event firing
  • Robust focus management requires multiple event listeners

Extension points

Section titled Extension points

Custom interaction controllers

Section titled Custom interaction controllers

Create a custom controller by extending InteractionController:

class CustomController extends InteractionController {
    override type = InteractionTypes.custom;

    override init(): void {
        this.abortController = new AbortController();
        const { signal } = this.abortController;

        this.target.addEventListener(
            'customevent',
            () => {
                this.open = !this.open;
            },
            { signal }
        );
    }
}

Custom overlay types

Section titled Custom overlay types

While not officially supported, you can extend the Overlay class for specialized behavior:

class CustomOverlay extends Overlay {
    constructor() {
        super();
        // Custom initialization
    }

    // Override methods as needed
}

Note: Extending Overlay should be done carefully as internal APIs may change.

Testing considerations

Section titled Testing considerations

Unit testing overlays

Section titled Unit testing overlays

Key aspects to test:

State transitions:

it('should open and close', async () => {
    overlay.open = true;
    await overlay.updateComplete;
    expect(overlay.hasAttribute('open')).to.be.true;

    overlay.open = false;
    await overlay.updateComplete;
    expect(overlay.hasAttribute('open')).to.be.false;
});

Event firing:

it('should dispatch sp-opened event', async () => {
    const listener = spy();
    overlay.addEventListener('sp-opened', listener);

    overlay.open = true;
    await oneEvent(overlay, 'sp-opened');

    expect(listener).to.have.been.calledOnce;
});

Positioning:

it('should position relative to trigger', async () => {
    overlay.trigger = 'button@click';
    overlay.placement = 'bottom';
    overlay.open = true;
    await overlay.updateComplete;

    const triggerRect = trigger.getBoundingClientRect();
    const overlayRect = overlay.getBoundingClientRect();

    expect(overlayRect.top).to.be.greaterThan(triggerRect.bottom);
});

Integration testing

Section titled Integration testing

Test real-world scenarios:

  • Multiple overlays open simultaneously
  • Nested overlays
  • Focus management
  • Keyboard navigation
  • Touch interactions
  • Responsive behavior

Future considerations

Section titled Future considerations

Proposed improvements

Section titled Proposed improvements

Simplified controller API:

  • Extract common AbortController patterns
  • Unified cleanup method
  • Better separation of concerns

Performance monitoring:

  • Track overlay open/close timing
  • Measure positioning calculation performance
  • Identify bottlenecks in large applications

Enhanced accessibility:

  • Automated focus trap testing
  • Screen reader testing tools
  • Keyboard navigation validation

Better TypeScript support:

  • Stronger type checking for overlay options
  • Generic types for custom overlays
  • Improved IDE autocomplete

Additional resources

Section titled Additional resources
  • Getting Started Guide
  • Troubleshooting Guide
  • Accessibility Guide
  • Performance Guide
  • Floating UI Documentation
  • Focus Trap Library