This document provides a deep dive into the technical architecture of the overlay system for contributors and advanced users.
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 │
└─────────────────┘ └─────────────────┘ └────────────────┘
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.
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 visibilitytype: Determines interaction model (modal,page,hint,auto,manual)placement: Position relative to triggertrigger: String reference to trigger element with interaction typetriggerElement: Direct element orVirtualTriggerreferencedelayed: Enables warm-up/cool-down timingreceivesFocus: Controls focus behavior
Key methods:
bindEvents(): Sets up interaction controllersmanageDelay(): Handles delayed opening logichandleBeforetoggle(): Prepares overlay state before visibility changeshandleTransitionEvents(): Tracks CSS transitions forsp-opened/sp-closedevents
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 follow the Reactive Controller pattern and manage the relationship between trigger elements and overlays.
All controllers extend InteractionController which provides:
Core functionality:
openproperty: Manages overlay stateoverlayproperty: Reference to associated overlay with automatic bindingisPersistentflag: Controls initialization timinghostConnected()/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()
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, setpreventNextToggle = true - On
click: Toggle overlay unlesspreventNextToggleis set - This prevents closing and immediately reopening when clicking the trigger
Use cases:
- Dropdown menus
- Modal dialogs
- Expandable panels
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-describedbylinking trigger to tooltip content - Responds to ESC key to close and return focus
- Handles
:focus-visibleto avoid showing on click interactions
Use cases:
- Tooltips
- Hover cards
- Info popovers
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-describedbywith longpress instructions - Descriptor text customizable via
longpress-describedby-descriptorslot
Use cases:
- Mobile context menus
- Hold-to-reveal actions
- Alternative interaction methods
The PlacementController manages overlay positioning using Floating UI.
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 overlayflip: Switches to fallback placement when constrainedshift: Slides overlay along axis to stay in viewsize: Adjusts overlay dimensions to fit viewportarrow: Positions arrow element (if present)
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`;
},
}),
],
};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.
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;
}The OverlayStack class manages all open overlays globally.
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" means closing an overlay when interacting outside it. The stack manages this by:
- Listening for clicks at capture phase
- Checking if click target is within current overlay
- Closing overlay if click is outside (for
autotype)
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 }
);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
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
transitioncancelevents - Uses multiple
requestAnimationFramecalls to catch WebKit early firing
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;
};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.
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.
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
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
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.
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
WebKit clip bug: WebKit bug #160953
- Affects
position: fixedin 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
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 }
);
}
}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.
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);
});Test real-world scenarios:
- Multiple overlays open simultaneously
- Nested overlays
- Focus management
- Keyboard navigation
- Touch interactions
- Responsive behavior
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