This document provides comprehensive implementation guidance for the modal focus management, stacking, and scroll lock behavior system. It includes Figma specifications, component templates, and detailed handoff instructions for engineering teams.
Fluxora Design System
Components
Modals
BaseModal
States
- Default
- Loading
- Error
- Success
Variants
- Small (320px max-width)
- Medium (520px max-width)
- Large (720px max-width)
- Full (90vw max-width)
ModalBackdrop
States
- Single Modal
- Stacked Modals (Depth 1, 2, 3+)
ModalContent
Headers
- With Title
- Without Title
- With Close Button
- Without Close Button
Bodies
- Form Content
- Text Content
- List Content
- Media Content
Footers
- Single Action
- Dual Actions
- No Actions
/* Modal-specific tokens from design-tokens.css */
--modal-backdrop-z-index: 1000;
--modal-z-index: 1100;
--modal-high-z-index: 1200;
--modal-critical-z-index: 1300;
--modal-backdrop-background: rgba(2, 8, 18, 0.8);
--modal-backdrop-blur: 5px;
--modal-background: var(--surface-neutral);
--modal-border: 1px solid var(--border-neutral);
--modal-border-radius: var(--radius-xl);
--modal-shadow: var(--shadow-xl);
--modal-padding-x: clamp(18px, 5vw, 32px);
--modal-padding-y: clamp(20px, 5vw, 32px);
--modal-max-width: 90vw;
--modal-max-height: 90vh;
--modal-animation-duration: 200ms;
--modal-animation-easing: var(--ease-out);Component: BaseModal - Default
- Backdrop: 80% opacity blur
- Modal: Centered, elevated shadow
- Focus: First focusable element highlighted
- Animation: Fade in + scale up (0.95 to 1.0)
- Z-Index: Based on priority level
Component: BaseModal - Loading
- Backdrop: Same as default
- Modal: Loading spinner in center
- Content: Dimmed or skeleton loading
- Focus: Loading indicator or first available element
- Interactions: Disabled during loading
- Animation: Spinner rotation, content dimming
Component: BaseModal - Error
- Backdrop: Same as default
- Modal: Error icon and message
- Focus: Error message or recovery action
- Color: Error theme colors (red accents)
- Animation: Shake or pulse for attention
Component: BaseModal - Success
- Backdrop: Same as default
- Modal: Success icon and message
- Focus: Success confirmation or next action
- Color: Success theme colors (green accents)
- Animation: Checkmark animation or gentle pulse
Focus Ring:
- Width: 2px
- Color: var(--color-focus)
- Offset: 2px
- Border-radius: inherit from element
- Animation: Smooth transition (150ms)
Focus States:
- Default: Visible focus ring
- Hover: Focus ring + background highlight
- Active: Focus ring + pressed state
- Disabled: No focus ring
Visual Focus Path:
1. Close button (if present)
2. Modal title (if focusable)
3. Primary content area
4. Form inputs (if present)
5. Primary action button
6. Secondary action button
7. Cancel/close button (if not in header)
Focus Trap Boundaries:
- Visual boundary: Modal container
- Keyboard boundary: Focusable elements within modal
- Screen reader boundary: aria-modal="true"
Z-Index Stack:
- Page content: 0-999
- Modal backdrop: 1000
- Modal content: 1100
- Toast notifications: 2000
Visual Layers:
- Backdrop blur effect
- Modal elevation shadow
- Focus ring visibility
Stack Depth 1 (Parent Modal):
- Z-Index: 1100
- Backdrop opacity: 60%
- Scale: 0.95
- Blur: Slight backdrop blur
Stack Depth 2 (Child Modal):
- Z-Index: 1200
- Backdrop opacity: 80%
- Scale: 1.0
- Blur: Full backdrop blur
Stack Depth 3+ (Grandchild Modal):
- Z-Index: 1300+
- Backdrop opacity: 90%
- Scale: 1.0
- Blur: Maximum backdrop blur
// src/components/modal/BaseModal.tsx
import React, { useRef, useEffect, useState } from 'react';
import { useModalManager, useModalAccessibility } from './hooks';
import styles from './BaseModal.module.css';
export interface BaseModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
description?: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
size?: 'small' | 'medium' | 'large' | 'full';
initialFocusRef?: React.RefObject<HTMLElement>;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;
preventClose?: boolean;
showCloseButton?: boolean;
className?: string;
children: React.ReactNode;
}
export const BaseModal: React.FC<BaseModalProps> = ({
isOpen,
onClose,
title,
description,
priority = 'medium',
size = 'medium',
initialFocusRef,
closeOnBackdropClick = true,
closeOnEscape = true,
preventClose = false,
showCloseButton = true,
className,
children,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const [isVisible, setIsVisible] = useState(false);
const modalId = `modal-${React.useId()}`;
// Modal management
const { zIndex, stackDepth, isActive, bringToFront } = useModalManager({
id: modalId,
isOpen,
priority,
element: modalRef,
onClose,
});
// Accessibility management
const { focusableElements, currentFocus, setFocus } = useModalAccessibility({
isOpen,
onClose,
modalRef,
initialFocusRef: initialFocusRef || closeButtonRef,
restoreFocus: true,
trapFocus: true,
});
// Animation handling
useEffect(() => {
if (isOpen) {
setIsVisible(true);
// Bring to front when opened
bringToFront();
} else {
// Handle exit animation
const timer = setTimeout(() => setIsVisible(false), 200);
return () => clearTimeout(timer);
}
}, [isOpen, bringToFront]);
// Handle backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (closeOnBackdropClick && !preventClose && e.target === e.currentTarget) {
onClose();
}
};
// Handle escape key
useEffect(() => {
if (!closeOnEscape || !isOpen || preventClose) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [closeOnEscape, isOpen, preventClose, onClose]);
if (!isVisible) return null;
return (
<div
className={`${styles.backdrop} ${styles[`stack-depth-${Math.min(stackDepth, 3)}`]} ${className}`}
onClick={handleBackdropClick}
style={{ zIndex: zIndex - 1 }}
>
<div
ref={modalRef}
className={`${styles.modal} ${styles[size]} ${styles[priority]} ${!isActive ? styles.inactive : ''}`}
style={{ zIndex }}
role="dialog"
aria-modal="true"
aria-labelledby={title ? `${modalId}-title` : undefined}
aria-describedby={description ? `${modalId}-description` : undefined}
>
{/* Header */}
{(title || showCloseButton) && (
<div className={styles.header}>
{title && (
<h2 id={`${modalId}-title`} className={styles.title}>
{title}
</h2>
)}
{showCloseButton && (
<button
ref={closeButtonRef}
type="button"
className={styles.closeButton}
onClick={onClose}
disabled={preventClose}
aria-label="Close modal"
>
<span aria-hidden="true">×</span>
</button>
)}
</div>
)}
{/* Description */}
{description && (
<p id={`${modalId}-description`} className={styles.description}>
{description}
</p>
)}
{/* Content */}
<div className={styles.content}>
{children}
</div>
</div>
</div>
);
};/* src/components/modal/BaseModal.module.css */
/* Backdrop */
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--modal-backdrop-background);
backdrop-filter: blur(var(--modal-backdrop-blur));
display: flex;
align-items: center;
justify-content: center;
padding: var(--modal-padding-x);
transition: opacity var(--modal-animation-duration) var(--modal-animation-easing);
}
/* Stack depth variations */
.stack-depth-1 {
background: rgba(2, 8, 18, 0.6);
}
.stack-depth-2 {
background: rgba(2, 8, 18, 0.7);
}
.stack-depth-3 {
background: rgba(2, 8, 18, 0.8);
}
/* Modal container */
.modal {
position: relative;
background: var(--modal-background);
border: var(--modal-border);
border-radius: var(--modal-border-radius);
box-shadow: var(--modal-shadow);
max-width: var(--modal-max-width);
max-height: var(--modal-max-height);
width: 100%;
overflow-y: auto;
font-family: var(--font-family-base);
animation: modalEnter var(--modal-animation-duration) var(--modal-animation-easing);
transform-origin: center;
}
/* Modal sizes */
.small {
max-width: 320px;
}
.medium {
max-width: 520px;
}
.large {
max-width: 720px;
}
.full {
max-width: 90vw;
height: 90vh;
}
/* Priority variations */
.low {
border-color: var(--border-neutral);
}
.medium {
border-color: var(--border-interactive);
}
.high {
border-color: var(--color-accent-primary);
}
.critical {
border-color: var(--color-danger);
}
/* Inactive state (stacked modals) */
.inactive {
opacity: 0.8;
transform: scale(0.95);
pointer-events: none;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--modal-padding-y) var(--modal-padding-x) 0;
gap: 1rem;
}
.title {
font: var(--font-heading-3);
color: var(--text-vivid);
margin: 0;
flex: 1;
min-width: 0;
}
.closeButton {
background: transparent;
border: 1px solid transparent;
color: var(--text-muted);
font-size: 1.5rem;
line-height: 1;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.closeButton:hover:not(:disabled) {
background: var(--surface-elevated);
color: var(--text-secondary);
}
.closeButton:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.closeButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Description */
.description {
font: var(--font-body-md);
color: var(--text-secondary);
margin: 0 0 var(--space-lg);
padding: 0 var(--modal-padding-x);
}
/* Content */
.content {
padding: 0 var(--modal-padding-x) var(--modal-padding-y);
}
/* Animations */
@keyframes modalEnter {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.backdrop {
padding: var(--space-md);
}
.modal {
max-width: 100%;
max-height: 100vh;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.header {
padding: var(--space-lg) var(--space-md) 0;
}
.content {
padding: 0 var(--space-md) var(--space-lg);
}
}// src/components/modal/hooks/useModalManager.ts
import { useEffect, useState, useCallback } from 'react';
import { ModalManager } from '../ModalManager';
export interface UseModalManagerOptions {
id: string;
isOpen: boolean;
priority: 'low' | 'medium' | 'high' | 'critical';
element: React.RefObject<HTMLElement>;
onClose: () => void;
}
export interface UseModalManagerReturn {
zIndex: number;
stackDepth: number;
isActive: boolean;
bringToFront: () => void;
}
const PRIORITY_MAP = {
low: 1100,
medium: 1200,
high: 1300,
critical: 1400,
};
export const useModalManager = ({
id,
isOpen,
priority,
element,
onClose,
}: UseModalManagerOptions): UseModalManagerReturn => {
const [zIndex, setZIndex] = useState(PRIORITY_MAP[priority]);
const [stackDepth, setStackDepth] = useState(0);
const [isActive, setIsActive] = useState(false);
// Register/unregister modal
useEffect(() => {
if (isOpen && element.current) {
ModalManager.register({
id,
priority: PRIORITY_MAP[priority],
element: element.current,
onClose,
timestamp: Date.now(),
});
} else {
ModalManager.unregister(id);
}
}, [id, isOpen, priority, element, onClose]);
// Subscribe to modal stack changes
useEffect(() => {
const unsubscribe = ModalManager.subscribe((stack) => {
const modalIndex = stack.modals.findIndex(m => m.id === id);
if (modalIndex !== -1) {
const modal = stack.modals[modalIndex];
setZIndex(modal.priority);
setStackDepth(stack.modals.length - modalIndex - 1);
setIsActive(stack.activeModal?.id === id);
}
});
return unsubscribe;
}, [id]);
const bringToFront = useCallback(() => {
ModalManager.bringToFront(id);
}, [id]);
return {
zIndex,
stackDepth,
isActive,
bringToFront,
};
};// src/components/modal/hooks/useModalAccessibility.ts
import { useEffect, useState, useCallback, useMemo } from 'react';
import { ScrollLockManager } from '../ScrollLockManager';
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
export interface UseModalAccessibilityOptions {
isOpen: boolean;
onClose: () => void;
modalRef: React.RefObject<HTMLElement>;
initialFocusRef?: React.RefObject<HTMLElement>;
restoreFocus?: boolean;
trapFocus?: boolean;
}
export interface UseModalAccessibilityReturn {
focusableElements: HTMLElement[];
currentFocus: HTMLElement | null;
setFocus: (element: HTMLElement) => void;
}
export const useModalAccessibility = ({
isOpen,
onClose,
modalRef,
initialFocusRef,
restoreFocus = true,
trapFocus = true,
}: UseModalAccessibilityOptions): UseModalAccessibilityReturn => {
const [currentFocus, setCurrentFocus] = useState<HTMLElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Get focusable elements
const focusableElements = useMemo(() => {
if (!modalRef.current) return [];
return Array.from(modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
.filter((element) => {
if (element.hasAttribute('hidden')) return false;
if (element.getAttribute('aria-hidden') === 'true') return false;
if (element.tabIndex < 0) return false;
if (element instanceof HTMLInputElement && element.type === 'hidden') return false;
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
});
}, [modalRef]);
// Set focus to element
const setFocus = useCallback((element: HTMLElement) => {
element.focus();
setCurrentFocus(element);
}, []);
// Handle modal open/close
useEffect(() => {
if (!isOpen) return;
// Save previous focus
previousFocusRef.current = document.activeElement as HTMLElement;
// Apply scroll lock
ScrollLockManager.lock();
// Set initial focus
const focusTarget = initialFocusRef?.current ??
focusableElements[0] ??
modalRef.current;
if (focusTarget) {
// Use requestAnimationFrame to ensure DOM is ready
const frameId = requestAnimationFrame(() => {
focusTarget.focus();
setCurrentFocus(focusTarget);
});
return () => cancelAnimationFrame(frameId);
}
// Setup focus trap
if (trapFocus) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const currentModal = modalRef.current;
if (!currentModal) return;
const elements = focusableElements;
if (elements.length === 0) {
e.preventDefault();
currentModal.focus();
return;
}
const firstElement = elements[0];
const lastElement = elements[elements.length - 1];
const activeElement = document.activeElement;
// If focus is outside modal, bring it back
if (!currentModal.contains(activeElement)) {
e.preventDefault();
(e.shiftKey ? lastElement : firstElement).focus();
return;
}
// Handle tab cycling
if (e.shiftKey && activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
ScrollLockManager.unlock();
if (restoreFocus && previousFocusRef.current) {
requestAnimationFrame(() => {
previousFocusRef.current?.focus();
});
}
};
}
return () => {
ScrollLockManager.unlock();
};
}, [isOpen, modalRef, initialFocusRef, focusableElements, trapFocus, restoreFocus, setFocus]);
// Track current focus changes
useEffect(() => {
const handleFocusChange = () => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && modalRef.current?.contains(activeElement)) {
setCurrentFocus(activeElement);
}
};
document.addEventListener('focusin', handleFocusChange);
return () => document.removeEventListener('focusin', handleFocusChange);
}, [modalRef]);
return {
focusableElements,
currentFocus,
setFocus,
};
};- Implement BaseModal component with all props
- Add CSS module styles for all variants
- Implement animation states (enter/exit)
- Add responsive design breakpoints
- Test with different sizes and priorities
- Implement ModalManager class
- Create useModalManager hook
- Add modal registration/unregistration
- Implement stacking logic
- Add z-index management
- Implement useModalAccessibility hook
- Add ScrollLockManager class
- Implement focus trap logic
- Add focus restoration
- Test with screen readers
- Update ConnectWalletModal to use BaseModal
- Update CreateStreamModal to use new hooks
- Update StreamCreatedModal to use BaseModal
- Remove duplicate accessibility logic
- Test all existing modal flows
- Create ConfirmationDialog component
- Create ErrorDialog component
- Create InfoDialog component
- Add LoadingOverlay component
- Test all new modal types
- BaseModal component tests
- useModalManager hook tests
- useModalAccessibility hook tests
- ScrollLockManager tests
- ModalManager class tests
- Modal stacking behavior
- Focus management across modals
- Scroll lock behavior
- Accessibility compliance
- Cross-browser compatibility
- Screen reader testing (NVDA, JAWS, VoiceOver)
- Keyboard navigation testing
- Focus management verification
- ARIA attribute validation
- Color contrast testing
- Modal open/close <100ms
- Focus management <50ms
- Animation performance 60fps
- Memory usage monitoring
- Bundle size impact assessment
- Screen reader announcement timing
- Focus transition smoothness
- Keyboard response time
- ARIA attribute efficiency
- Assistive technology compatibility
- Chrome (latest + 2 previous)
- Firefox (latest + 2 previous)
- Safari (latest + 2 previous)
- Edge (latest + 2 previous)
- iOS Safari (latest + 2 previous)
- Chrome Mobile (latest + 2 previous)
- Samsung Internet (latest)
- Firefox Mobile (latest)
// Before
export default function ConnectWalletModal(props) {
// Custom focus management
// Custom scroll lock
// Inline styles
}
// After
export default function ConnectWalletModal(props) {
return (
<BaseModal
isOpen={props.isOpen}
onClose={props.onClose}
title="Choose your wallet"
description="Select a provider below to connect"
priority="medium"
size="medium"
>
{/* Modal content */}
</BaseModal>
);
}// Before
export default function CreateStreamModal(props) {
useModalAccessibility({ /* custom implementation */ });
// Custom modal logic
}
// After
export default function CreateStreamModal(props) {
const initialFocusRef = useRef<HTMLInputElement>(null);
return (
<BaseModal
isOpen={props.isOpen}
onClose={props.onClose}
title="Create Stream"
initialFocusRef={initialFocusRef}
priority="high"
size="large"
>
{/* Multi-step form content */}
</BaseModal>
);
}export interface ConfirmationDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'default' | 'destructive';
}
export const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'default',
}) => {
return (
<BaseModal
isOpen={isOpen}
onClose={onClose}
title={title}
description={message}
priority={variant === 'destructive' ? 'critical' : 'medium'}
size="small"
>
<div className="flex gap-3 justify-end">
<button onClick={onClose}>{cancelText}</button>
<button onClick={onConfirm}>{confirmText}</button>
</div>
</BaseModal>
);
};Problem: Focus not trapped in modal Solution:
- Check focusable elements detection
- Verify ARIA attributes
- Test with different screen readers
Problem: Focus not restored on close Solution:
- Check previous focus element reference
- Verify element is still in DOM
- Test focus restoration timing
Problem: Page still scrollable with modal open Solution:
- Check scroll lock counter
- Verify body style application
- Test scrollbar width calculation
Problem: Layout shifts when modal opens Solution:
- Verify scrollbar compensation
- Check padding-right application
- Test with different content heights
Problem: Modal appears behind other elements Solution:
- Check z-index values
- Verify modal registration
- Test stacking order
Problem: Multiple modals don't stack correctly Solution:
- Check modal manager state
- Verify priority handling
- Test focus transfer between modals
// Development tool for inspecting modal state
if (process.env.NODE_ENV === 'development') {
window.modalInspector = {
getActiveModal: () => ModalManager.getActiveModal(),
getModalStack: () => ModalManager.getStack(),
getScrollLockState: () => ScrollLockManager.getState(),
debugFocus: () => console.log(document.activeElement),
};
}/* Development focus visualization */
.focus-debug *:focus {
outline: 3px solid red !important;
outline-offset: 2px !important;
}// Performance monitoring for modals
const modalMetrics = {
openTime: 0,
closeTime: 0,
focusSetTime: 0,
renderTime: 0,
};
// Measure modal performance
const measureModalPerformance = (modalId: string, action: string) => {
const start = performance.now();
return () => {
const end = performance.now();
console.log(`Modal ${modalId} ${action}: ${end - start}ms`);
};
};/**
* BaseModal Component
*
* A flexible modal component with built-in accessibility,
* focus management, and stacking support.
*
* @example
* ```tsx
* <BaseModal
* isOpen={isOpen}
* onClose={handleClose}
* title="Modal Title"
* priority="medium"
* size="large"
* >
* <p>Modal content</p>
* </BaseModal>
* ```
*//**
* useModalManager Hook
*
* Manages modal registration, stacking, and z-index.
* Automatically handles modal lifecycle and priority.
*
* @param options - Modal manager configuration
* @returns Modal state and controls
*/- Simple modal with title and content
- Modal with custom focus management
- Modal with different priorities
- Modal with custom sizes
- Nested modal scenarios
- Modal with dynamic content
- Modal with custom animations
- Modal with complex forms
- When to use modals vs other UI patterns
- How to structure modal content
- Accessibility considerations
- Performance optimization tips
- Confirmation dialogs
- Form modals
- Error handling modals
- Loading states
Document Version: 1.0
Last Updated: 2025-04-29
Implementation Target: Sprint 2
Code Review Required: Yes
QA Testing Required: Yes
Accessibility Review Required: Yes