Mobile-First Modals — Complete Guide
In this tutorial, you will learn about Mobile. We cover key concepts, practical examples, and best practices to help you master this topic.
Mobile-first modals use bottom sheets, full-screen overlays, safe-area insets, focus trapping, and gesture-based dismissal for dialogs on small screens.
What You'll Learn
- Bottom sheet pattern
- Full-screen modal for complex content
- Backdrop overlay and scrolling
- Focus trapping for Accessibility
- Swipe-to-dismiss gesture
- Safe area insets for notched devices
- Modal animation and transitions
Why It Matters
- Desktop-style centered modals are unusable on mobile
- Content above the fold disappears behind the keyboard
- Small close buttons are hard to tap
- Poor focus management traps keyboard users
Real-World Use
- A share sheet sliding up from the bottom
- A full-screen image viewer
- A confirmation dialog for destructive actions
- A filter panel in an e-commerce app
flowchart LR A[Mobile-First Modals] --> B[Bottom Sheet] A --> C[Full Screen] A --> D[Accessibility] A --> E[Dismissal] B --> F[Slide up] C --> G[Cover viewport] D --> H[Focus trap] E --> I[Swipe down + tap backdrop]
Bottom Sheet Modal
The bottom sheet slides up from the bottom, keeping the title and primary action near the user's thumb.
Code Example: Bottom Sheet
<button id="open-sheet" class="btn btn-primary">Open Share Sheet</button>
<div class="modal-overlay" id="sheet-overlay" hidden>
<div class="bottom-sheet" role="dialog" aria-modal="true" aria-label="Share options">
<div class="sheet-handle"></div>
<div class="sheet-header">
<h2>Share</h2>
<button class="sheet-close" aria-label="Close share sheet">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div class="sheet-content">
<div class="share-options">
<button class="share-option">
<span class="share-icon">📱</span>
<span>Messages</span>
</button>
<button class="share-option">
<span class="share-icon">📧</span>
<span>Email</span>
</button>
<button class="share-option">
<span class="share-icon">🔗</span>
<span>Copy Link</span>
</button>
<button class="share-option">
<span class="share-icon">🐦</span>
<span>Twitter</span>
</button>
</div>
<div class="sheet-actions">
<button class="btn btn-secondary sheet-cancel">Cancel</button>
</div>
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
opacity: 0;
transition: opacity 0.25s ease;
}
.modal-overlay.open {
opacity: 1;
}
.bottom-sheet {
width: 100%;
max-width: 480px;
max-height: 85vh;
background: #fff;
border-radius: 16px 16px 0 0;
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-overlay.open .bottom-sheet {
transform: translateY(0);
}
.sheet-handle {
width: 36px;
height: 4px;
background: #d1d5db;
border-radius: 2px;
margin: 0.5rem auto;
flex-shrink: 0;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem 1rem;
border-bottom: 1px solid #e5e7eb;
}
.sheet-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.sheet-close {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
-webkit-tap-highlight-color: transparent;
}
.sheet-close:active {
background: #f3f4f6;
}
.sheet-content {
padding: 1rem;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.share-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.share-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
cursor: pointer;
min-height: 80px;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.share-option:active {
background: #f9fafb;
}
.share-icon {
font-size: 1.75rem;
}
.share-option span:last-child {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
}
.sheet-actions {
margin-top: 1rem;
}
.sheet-cancel {
width: 100%;
}
</style>
<script>
const overlay = document.getElementById('sheet-overlay');
const openBtn = document.getElementById('open-sheet');
const closeBtn = overlay.querySelector('.sheet-close');
const cancelBtn = overlay.querySelector('.sheet-cancel');
function openSheet() {
overlay.hidden = false;
requestAnimationFrame(() => overlay.classList.add('open'));
document.body.style.overflow = 'hidden';
}
function closeSheet() {
overlay.classList.remove('open');
overlay.addEventListener('transitionend', function handler() {
overlay.hidden = true;
overlay.removeEventListener('transitionend', handler);
document.body.style.overflow = '';
});
}
openBtn.addEventListener('click', openSheet);
closeBtn.addEventListener('click', closeSheet);
cancelBtn.addEventListener('click', closeSheet);
overlay.addEventListener('click', function(e) {
if (e.target === this) closeSheet();
});
</script>
Expected output: Clicking "Open Share Sheet" slides a bottom sheet up from the bottom. The handle bar indicates it can be dragged. The sheet has a close button and cancel action. Tapping the backdrop closes the sheet.
Full-Screen Modal
For complex content, use a full-screen modal that covers the entire viewport.
Code Example: Full-Screen Modal
<button id="open-fullscreen" class="btn btn-primary">Open Filter Panel</button>
<div class="fullscreen-modal" id="fullscreen-modal" hidden>
<div class="modal-header">
<button class="modal-back" aria-label="Close filters">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</button>
<h1>Filters</h1>
<button class="modal-reset">Reset</button>
</div>
<div class="modal-body">
<div class="filter-section">
<h3>Category</h3>
<div class="filter-chips">
<button class="chip active">All</button>
<button class="chip">Electronics</button>
<button class="chip">Clothing</button>
<button class="chip">Books</button>
</div>
</div>
<div class="filter-section">
<h3>Price Range</h3>
<div class="price-inputs">
<input type="number" class="touch-input" placeholder="Min" inputmode="numeric">
<span>to</span>
<input type="number" class="touch-input" placeholder="Max" inputmode="numeric">
</div>
</div>
<div class="filter-section">
<h3>Rating</h3>
<div class="filter-chips">
<button class="chip">4+ Stars</button>
<button class="chip">3+ Stars</button>
<button class="chip">2+ Stars</button>
</div>
</div>
<div class="filter-section">
<h3>Availability</h3>
<label class="toggle-row">
<span>In Stock Only</span>
<input type="checkbox" class="toggle-input" checked>
<span class="toggle-slider"></span>
</label>
<label class="toggle-row">
<span>Free Shipping</span>
<input type="checkbox" class="toggle-input">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-close">Cancel</button>
<button class="btn btn-primary">Apply Filters (42)</button>
</div>
</div>
<style>
.fullscreen-modal {
position: fixed;
inset: 0;
z-index: 1000;
background: #fff;
display: flex;
flex-direction: column;
animation: slideInRight 0.3s ease;
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
min-height: 56px;
}
.modal-header h1 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.modal-back,
.modal-reset {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.9375rem;
font-weight: 500;
color: #3b82f6;
-webkit-tap-highlight-color: transparent;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
-webkit-overflow-scrolling: touch;
}
.filter-section {
margin-bottom: 1.5rem;
}
.filter-section h3 {
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
margin: 0 0 0.75rem;
}
.price-inputs {
display: flex;
align-items: center;
gap: 0.5rem;
}
.price-inputs input {
width: 100%;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
min-height: 44px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.toggle-input {
display: none;
}
.toggle-slider {
width: 44px;
height: 24px;
background: #d1d5db;
border-radius: 12px;
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-slider::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.toggle-input:checked + .toggle-slider {
background: #3b82f6;
}
.toggle-input:checked + .toggle-slider::after {
transform: translateX(20px);
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1rem;
border-top: 1px solid #e5e7eb;
background: #fff;
}
.modal-footer .btn {
flex: 1;
}
</style>
<script>
const fullscreenModal = document.getElementById('fullscreen-modal');
const openFullscreen = document.getElementById('open-fullscreen');
openFullscreen.addEventListener('click', () => {
fullscreenModal.hidden = false;
document.body.style.overflow = 'hidden';
});
fullscreenModal.querySelectorAll('.modal-back, .modal-close').forEach(btn => {
btn.addEventListener('click', () => {
fullscreenModal.hidden = true;
document.body.style.overflow = '';
});
});
</script>
Expected output: The filter panel slides in from the right, covering the full screen. The header has a back button and reset action. The body scrolls independently. The footer has sticky action buttons.
Focus Trapping
When a modal opens, keyboard focus must be trapped inside it to prevent users from tabbing behind the overlay.
Code Example: Focus Trap
function trapFocus(element) {
const focusable = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusable[0];
const lastFocusable = focusable[focusable.length - 1];
firstFocusable?.focus();
element.addEventListener('keydown', function handleKeyDown(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable?.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable?.focus();
}
}
});
return () => {
element.removeEventListener('keydown', handleKeyDown);
};
}
// Usage
const modal = document.querySelector('.bottom-sheet');
const releaseFocus = trapFocus(modal);
// Call releaseFocus() when modal closes
Expected output: When the modal opens, focus moves to the first focusable element. Tab and Shift+Tab cycle through focusable elements within the modal. Focus cannot escape the modal until it is closed.
Common Mistakes
- Centered modal on mobile — Centered dialogs place content above the thumb zone and often go below the keyboard.
- No backdrop close — Users must find a small close button instead of tapping outside to dismiss.
- No scroll prevention — The background page scrolls behind the modal, creating a confusing layered scroll.
- Small close button — The close button must be at least 44x44px. A simple X icon is hard to tap.
- No safe area insets — Notched devices show modal content behind the notch or home indicator.
- Gesture conflicts — Swipe-to-dismiss on a bottom sheet conflicts with page scroll if not implemented carefully.
- No focus trap — Keyboard users tab behind the modal and cannot return without closing it.
Practice Questions
- Why are bottom sheets preferred over centered modals on mobile? Bottom sheets keep the header and primary action near the user's thumb. Centered modals put content in the hard-to-reach middle of the screen.
- How do you prevent background scrolling when a modal is open? Set document.body.style.overflow = 'hidden' when the modal opens and restore it when closed.
- What is safe-area-inset-bottom? A CSS constant that accounts for the home indicator area on notched devices like iPhone X and newer.
- How do you trap focus inside a modal? Listen for Tab key events. When Shift+Tab on the first element, move to the last. When Tab on the last element, move to the first.
- What ARIA attributes should a modal have? role="dialog", aria-modal="true", and aria-label or aria-labelledby for the title.
Challenge
Build a full-screen image viewer modal. Features: (1) opens when clicking a thumbnail, (2) full-screen overlay with black background, (3) image centered with proper aspect ratio, (4) pinch-to-zoom gesture support, (5) swipe down to dismiss, (6) close button in top-right corner (44x44px), (7) image caption at the bottom with safe area padding, (8) focus trap for keyboard users, (9) Escape key and backdrop tap to close, (10) smooth fade transition.
FAQ
Mini Project
Build a complete modal system with three patterns: (1) a confirmation dialog (centered small dialog for delete actions with Cancel and Delete buttons), (2) a bottom sheet action picker (share options with icons, swipe-to-dismiss), (3) a full-screen filter panel (multiple filter sections, sticky header and footer, slide-in animation). Each modal must implement: focus trapping, backdrop close, scroll prevention, Escape key close, proper ARIA attributes, safe area insets, 44px minimum touch targets, and return focus to the trigger element on close.
What's Next
Continue with Lesson 11: Mobile-First Cards to design card layouts optimized for mobile interfaces.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro