Skip to content

Mobile-First Modals — Complete Guide

DodaTech Updated 2026-06-28 9 min read

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

  1. Centered modal on mobile — Centered dialogs place content above the thumb zone and often go below the keyboard.
  2. No backdrop close — Users must find a small close button instead of tapping outside to dismiss.
  3. No scroll prevention — The background page scrolls behind the modal, creating a confusing layered scroll.
  4. Small close button — The close button must be at least 44x44px. A simple X icon is hard to tap.
  5. No safe area insets — Notched devices show modal content behind the notch or home indicator.
  6. Gesture conflicts — Swipe-to-dismiss on a bottom sheet conflicts with page scroll if not implemented carefully.
  7. No focus trap — Keyboard users tab behind the modal and cannot return without closing it.

Practice Questions

  1. 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.
  2. 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.
  3. What is safe-area-inset-bottom? A CSS constant that accounts for the home indicator area on notched devices like iPhone X and newer.
  4. 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.
  5. 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

Should I use a bottom sheet or a full-screen modal?

Use a bottom sheet for simple actions and confirmations (share, delete, choose). Use a full-screen modal for complex content with multiple sections (filters, forms, image viewing).

How do I handle modals on devices with a notch?

Use env(safe-area-inset-top) and env(safe-area-inset-bottom) as padding. This prevents content from hiding behind the notch or home indicator.

Can modals be dismissed by swiping?

Yes, bottom sheets support swipe-down dismissal. Use touch events to track drag distance. If the user drags past a threshold (30% of height), dismiss the sheet. Otherwise, animate it back.

How do I manage focus when a modal closes?

Return focus to the element that triggered the modal. Store a reference to the trigger element (document.activeElement) before opening and call trigger.focus() after closing.

What z-index should modals use?

Use z-index: 1000 or higher. Reserve a specific range (1000-1999) for modals and overlays to avoid conflicts with other positioned elements.

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