ARCHITECTURE
NPM 1.10.0
View Storybook
Overlay system architecture
This document provides a deep dive into the technical architecture of the overlay system for contributors and advanced users.
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
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
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
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
Interaction controllers follow the
Base: InteractionController
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()
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, 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
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-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
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-describedbywith longpress instructions - Descriptor text customizable via
longpress-describedby-descriptorslot
Use cases:
- Mobile context menus
- Hold-to-reveal actions
- Alternative interaction methods
Placement system
The PlacementController manages overlay positioning using
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 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)
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
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
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
The OverlayStack class manages all open overlays globally.
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
"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 } );
State management
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
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
Event system
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
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
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
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
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
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
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
WebKit clip bug:
- 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
Extension points
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
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
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
Test real-world scenarios:
- Multiple overlays open simultaneously
- Nested overlays
- Focus management
- Keyboard navigation
- Touch interactions
- Responsive behavior
Future considerations
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
Getting Started Guide Troubleshooting Guide Accessibility Guide Performance Guide Floating UI Documentation Focus Trap Library