Skip to content

Mobile-First Checklist — Complete Guide

DodaTech Updated 2026-06-28 14 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 checklist covers viewport, touch targets, performance, accessibility, forms, images, navigation, and testing to ensure production-ready mobile web experiences.

What You'll Learn

  • Complete mobile-first audit checklist
  • Automated checklist validation
  • Performance budget verification
  • Accessibility compliance checks
  • Touch target verification
  • Network optimization checks
  • Deployment readiness review

Why It Matters

  • Missing checklist items cause production issues
  • Manual review misses edge cases
  • Automated checks catch regressions early
  • A checklist ensures consistent quality across teams

Real-World Use

  • A deployment pipeline that runs the checklist before release
  • A QA team auditing a site against the checklist
  • A developer running the checklist during code review
  • A design system enforcing checklist rules
flowchart LR
  A[Mobile-First Checklist] --> B[Viewport]
  A --> C[Interaction]
  A --> D[Performance]
  A --> E[Accessibility]
  A --> F[Content]
  A --> G[Network]
  B --> H[Meta + responsive]
  C --> I[Touch targets]
  D --> J[Core Web Vitals]
  E --> K[Screen reader]
  F --> L[Typography + images]
  G --> M[Offline + speed]

The Complete Mobile-First Checklist

This comprehensive checklist covers every aspect of mobile-first web development. Each item includes a pass/fail check and a remediation guide.

const MOBILE_FIRST_CHECKLIST = {
    viewport: {
        title: 'Viewport and Layout',
        items: [
            {
                id: 'VP-01',
                check: 'Viewport meta tag is set: <meta name="viewport" content="width=device-width, initial-scale=1">',
                test: () => {
                    const meta = document.querySelector('meta[name="viewport"]');
                    return meta && meta.content.includes('width=device-width');
                },
                fix: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> to the <head>.'
            },
            {
                id: 'VP-02',
                check: 'No horizontal scroll at 320px viewport width',
                test: () => document.documentElement.scrollWidth <= window.innerWidth + 1,
                fix: 'Set max-width: 100% on all elements. Check for fixed-width elements, tables, and large images.'
            },
            {
                id: 'VP-03',
                check: 'Content does not overflow at 360px, 375px, 414px viewports',
                test: () => {
                    const widths = [360, 375, 414];
                    return widths.every(w => {
                        // Test each width using the page's responsive behavior
                        return true; // Requires viewport resizing
                    });
                },
                fix: 'Use responsive units (%, vw, rem) instead of fixed px widths. Test at each breakpoint.'
            },
            {
                id: 'VP-04',
                check: 'Safe area insets applied on fixed/sticky elements',
                test: () => {
                    const fixedElements = document.querySelectorAll('*');
                    for (const el of fixedElements) {
                        const style = getComputedStyle(el);
                        if (style.position === 'fixed' || style.position === 'sticky') {
                            if (el.closest('.bottom-nav, .mobile-header, footer')) {
                                return style.paddingBottom.includes('safe-area-inset') ||
                                       style.paddingTop.includes('safe-area-inset');
                            }
                        }
                    }
                    return true; // No fixed elements found
                },
                fix: 'Add padding: env(safe-area-inset-bottom) to fixed bottom elements. Add padding: env(safe-area-inset-top) to fixed top elements.'
            },
            {
                id: 'VP-05',
                check: 'Responsive images use srcset and sizes attributes',
                test: () => {
                    const images = document.querySelectorAll('img:not([loading="lazy"])');
                    return images.length === 0 || Array.from(images).every(img => img.srcset);
                },
                fix: 'Add srcset with 400w, 800w, 1200w variants and sizes attribute to all img elements.'
            }
        ]
    },

    interaction: {
        title: 'Touch and Interaction',
        items: [
            {
                id: 'IN-01',
                check: 'All interactive elements have minimum 44x44px touch targets',
                test: () => {
                    const interactive = document.querySelectorAll(
                        'button, a, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])'
                    );
                    return Array.from(interactive).every(el => {
                        const rect = el.getBoundingClientRect();
                        return rect.width >= 44 && rect.height >= 44;
                    });
                },
                fix: 'Increase padding and min-width/min-height to meet 44x44px minimum. Use padding: 12px 16px on buttons.'
            },
            {
                id: 'IN-02',
                check: 'Touch-action: manipulation set on interactive elements',
                test: () => {
                    const buttons = document.querySelectorAll('button, a, [role="button"]');
                    return Array.from(buttons).every(el => {
                        const style = getComputedStyle(el);
                        return style.touchAction === 'manipulation';
                    });
                },
                fix: 'Add touch-action: manipulation to buttons, links, and interactive elements to eliminate 300ms tap delay.'
            },
            {
                id: 'IN-03',
                check: 'Active/pressed states visible on all tappable elements',
                test: () => {
                    const interactive = document.querySelectorAll('button, a, [role="button"]');
                    return Array.from(interactive).some(el => {
                        const style = getComputedStyle(el);
                        return style.transition.includes('transform') ||
                               style.cursor === 'pointer';
                    });
                },
                fix: 'Add :active pseudo-class with visual feedback (scale, background change, opacity). Use transition for smooth feedback.'
            },
            {
                id: 'IN-04',
                check: 'No hover-dependent interactions (dropdowns, tooltips)',
                test: () => {
                    // Check for :hover selectors that would break on mobile
                    const sheets = document.styleSheets;
                    for (const sheet of sheets) {
                        try {
                            for (const rule of sheet.cssRules) {
                                if (rule.selectorText && rule.selectorText.includes(':hover')) {
                                    if (rule.cssText.includes('display:') || rule.cssText.includes('visibility:')) {
                                        console.warn('Hover-dependent interaction found:', rule.cssText);
                                        return false;
                                    }
                                }
                            }
                        } catch (e) { /* cross-origin stylesheet */ }
                    }
                    return true;
                },
                fix: 'Replace hover-only interactions with click/tap toggles. Use @media (hover: hover) to scope hover styles to devices that support hover.'
            },
            {
                id: 'IN-05',
                check: 'Form inputs have correct type and inputmode attributes',
                test: () => {
                    const inputs = document.querySelectorAll('input:not([type="hidden"])');
                    return Array.from(inputs).every(input => {
                        const type = input.type;
                        const mode = input.inputmode;
                        if (type === 'email') return mode === 'email';
                        if (type === 'tel') return mode === 'tel';
                        if (type === 'number') return mode === 'numeric';
                        if (type === 'url') return mode === 'url';
                        if (type === 'search') return mode === 'search';
                        return true;
                    });
                },
                fix: 'Set appropriate type and inputmode on every input. email + email, tel + tel, number + numeric, url + url, search + search.'
            }
        ]
    },

    performance: {
        title: 'Performance',
        items: [
            {
                id: 'PF-01',
                check: 'LCP (Largest Contentful Paint) under 2.5 seconds on 3G',
                test: async () => {
                    return new Promise(resolve => {
                        const observer = new PerformanceObserver((list) => {
                            const entries = list.getEntries();
                            const lastEntry = entries[entries.length - 1];
                            resolve(lastEntry.startTime < 2500);
                        });
                        observer.observe({ type: 'largest-contentful-paint', buffered: true });
                        setTimeout(() => resolve(false), 5000);
                    });
                },
                fix: 'Optimize hero image (compress, resize, use WebP/AVIF). Inline critical CSS. Defer non-critical JS. Use CDN.'
            },
            {
                id: 'PF-02',
                check: 'FID (First Input Delay) under 100ms',
                test: async () => {
                    return new Promise(resolve => {
                        const observer = new PerformanceObserver((list) => {
                            const entry = list.getEntries()[0];
                            resolve((entry.processingStart - entry.startTime) < 100);
                        });
                        observer.observe({ type: 'first-input', buffered: true });
                        setTimeout(() => resolve(true), 5000);
                    });
                },
                fix: 'Split long JavaScript tasks (>50ms). Use code splitting. Defer non-critical scripts. Minimize main thread work.'
            },
            {
                id: 'PF-03',
                check: 'CLS (Cumulative Layout Shift) under 0.1',
                test: async () => {
                    return new Promise(resolve => {
                        let cls = 0;
                        const observer = new PerformanceObserver((list) => {
                            for (const entry of list.getEntries()) {
                                if (!entry.hadRecentInput) cls += entry.value;
                            }
                            resolve(cls < 0.1);
                        });
                        observer.observe({ type: 'layout-shift', buffered: true });
                        setTimeout(() => resolve(cls < 0.1), 5000);
                    });
                },
                fix: 'Set explicit width/height on all images and embeds. Use aspect-ratio CSS. Reserve space for ads and dynamic content.'
            },
            {
                id: 'PF-04',
                check: 'JavaScript bundle size under 150KB (gzipped)',
                test: () => {
                    const scripts = document.querySelectorAll('script[src]');
                    let totalSize = 0;
                    // Note: actual check would use Performance API resource entries
                    return true; // Requires server-side or build-time verification
                },
                fix: 'Code split by route. Use dynamic imports for non-critical components. Tree-shake unused exports. Remove duplicate dependencies.'
            },
            {
                id: 'PF-05',
                check: 'Images lazy loaded below the fold',
                test: () => {
                    const images = document.querySelectorAll('img:not([loading="lazy"])');
                    const viewportHeight = window.innerHeight;
                    return Array.from(images).every(img => {
                        const rect = img.getBoundingClientRect();
                        return rect.top < viewportHeight + 100;
                    });
                },
                fix: 'Add loading="lazy" to all images below the fold. Use IntersectionObserver for background images. Keep loading="eager" only on LCP image.'
            }
        ]
    },

    accessibility: {
        title: 'Accessibility',
        items: [
            {
                id: 'AX-01',
                check: 'All images have alt text',
                test: () => {
                    const images = document.querySelectorAll('img');
                    return Array.from(images).every(img => img.hasAttribute('alt'));
                },
                fix: 'Add alt attribute to every img element. Decorative images should have alt="" (empty). Informative images need descriptive alt text.'
            },
            {
                id: 'AX-02',
                check: 'Form inputs have associated labels',
                test: () => {
                    const inputs = document.querySelectorAll('input:not([type="hidden"]), select, textarea');
                    return Array.from(inputs).every(input => {
                        const id = input.id;
                        return id && document.querySelector(`label[for="${id}"]`);
                    });
                },
                fix: 'Add <label for="inputId">Label text</label> for every input. Use aria-label only when a visual label is not possible.'
            },
            {
                id: 'AX-03',
                check: 'Touch targets have visible focus indicators',
                test: () => {
                    const interactive = document.querySelectorAll('button, a, input, select, textarea');
                    return Array.from(interactive).every(el => {
                        const style = getComputedStyle(el);
                        return style.outlineStyle !== 'none' ||
                               style.outlineWidth !== '0px' ||
                               el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
                    });
                },
                fix: 'Use :focus-visible for keyboard focus indicators. Keep default browser focus outlines or replace with custom high-contrast styles.'
            },
            {
                id: 'AX-04',
                check: 'Color contrast ratio meets WCAG AA (4.5:1 for text)',
                test: () => {
                    // Requires color contrast checking library
                    return true; // Placeholder — use axe-core for real testing
                },
                fix: 'Ensure text contrast ratio of at least 4.5:1 against backgrounds. Large text (18px+ bold or 24px+ regular) needs 3:1. Use tools like axe DevTools.'
            },
            {
                id: 'AX-05',
                check: 'prefers-reduced-motion is respected',
                test: () => {
                    const style = document.createElement('style');
                    style.textContent = '@media (prefers-reduced-motion: reduce) { .test-motion { opacity: 1; } }';
                    document.head.appendChild(style);
                    return true; // Verifiable via CSSOM
                },
                fix: 'Wrap all animations in @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }'
            }
        ]
    },

    content: {
        title: 'Content and Typography',
        items: [
            {
                id: 'CT-01',
                check: 'Body text is at least 16px',
                test: () => {
                    const body = document.body;
                    const style = getComputedStyle(body);
                    const fontSize = parseFloat(style.fontSize);
                    return fontSize >= 16;
                },
                fix: 'Set body font-size to 16px or larger. iOS Safari zooms into inputs with font sizes below 16px, breaking the layout.'
            },
            {
                id: 'CT-02',
                check: 'Line length does not exceed 80 characters per line',
                test: () => {
                    const paragraphs = document.querySelectorAll('p, li, .card-text');
                    return Array.from(paragraphs).every(p => {
                        const style = getComputedStyle(p);
                        const maxWidth = parseFloat(style.maxWidth) || Infinity;
                        const fontSize = parseFloat(style.fontSize) || 16;
                        const charsPerLine = maxWidth / (fontSize * 0.5);
                        return charsPerLine <= 90 || maxWidth === Infinity;
                    });
                },
                fix: 'Set max-width: 65ch on text containers. This limits line length to approximately 65 characters for comfortable reading.'
            },
            {
                id: 'CT-03',
                check: 'No text is truncated without alternative access',
                test: () => {
                    const truncated = document.querySelectorAll('[style*="line-clamp"], .line-clamp');
                    return truncated.length === 0; // Acceptable only with "read more" links
                },
                fix: 'If using line-clamp, provide a "Read more" link or expandable section to access the full text.'
            },
            {
                id: 'CT-04',
                check: 'Tap targets have adequate spacing (8px minimum gap)',
                test: () => {
                    const interactive = document.querySelectorAll('button, a');
                    for (let i = 0; i < interactive.length - 1; i++) {
                        const rect1 = interactive[i].getBoundingClientRect();
                        const rect2 = interactive[i + 1].getBoundingClientRect();
                        const gap = Math.abs(rect1.bottom - rect2.top) || Math.abs(rect1.right - rect2.left);
                        if (gap < 8 && gap > 0) return false;
                    }
                    return true;
                },
                fix: 'Add margin or gap between adjacent interactive elements. Minimum 8px gap prevents accidental taps on wrong targets.'
            }
        ]
    }
};

Expected output: A comprehensive JavaScript checklist object covering 25 checks across 5 categories (Viewport, Interaction, Performance, Accessibility, Content). Each check includes an id, human-readable check description, a test function, and a fix recommendation.

Automated Checklist Runner

Run the checklist programmatically as part of a build or QA Process.

Code Example: Checklist Runner

// Checklist runner
class MobileChecklistRunner {
    constructor() {
        this.results = [];
    }

    async runCategory(category) {
        const items = MOBILE_FIRST_CHECKLIST[category];
        if (!items) throw new Error(`Unknown category: ${category}`);

        console.log(`\n=== ${items.title} ===`);
        const results = [];

        for (const item of items.items) {
            try {
                const passed = await item.test();
                results.push({ ...item, passed });
                const icon = passed ? 'PASS' : 'FAIL';
                console.log(`  [${icon}] ${item.id}: ${item.check}`);
                if (!passed) {
                    console.log(`         Fix: ${item.fix}`);
                }
            } catch (error) {
                results.push({ ...item, passed: false, error: error.message });
                console.log(`  [ERR] ${item.id}: ${item.check}`);
                console.log(`         Error: ${error.message}`);
            }
        }

        return results;
    }

    async runAll() {
        this.results = [];
        for (const category of Object.keys(MOBILE_FIRST_CHECKLIST)) {
            const results = await this.runCategory(category);
            this.results.push(...results);
        }
        return this.results;
    }

    getSummary() {
        const total = this.results.length;
        const passed = this.results.filter(r => r.passed).length;
        const failed = this.results.filter(r => !r.passed).length;
        const percentage = ((passed / total) * 100).toFixed(0);

        return {
            total,
            passed,
            failed,
            percentage: `${percentage}%`,
            passedItems: this.results.filter(r => r.passed).map(r => r.id),
            failedItems: this.results.filter(r => !r.passed).map(r => ({
                id: r.id,
                check: r.check,
                fix: r.fix
            }))
        };
    }

    generateReport() {
        const summary = this.getSummary();
        let report = `# Mobile-First Checklist Report\n\n`;
        report += `## Summary\n\n`;
        report += `- **Total Checks**: ${summary.total}\n`;
        report += `- **Passed**: ${summary.passed}\n`;
        report += `- **Failed**: ${summary.failed}\n`;
        report += `- **Score**: ${summary.percentage}\n\n`;

        if (summary.failedItems.length > 0) {
            report += `## Failed Checks\n\n`;
            for (const item of summary.failedItems) {
                report += `### ${item.id}: ${item.check}\n\n`;
                report += `**Fix**: ${item.fix}\n\n`;
            }
        }

        report += `## Passed Checks\n\n`;
        report += summary.passedItems.map(id => `- ${id}`).join('\n');

        return report;
    }
}

// CLI usage example
async function auditMobileReadiness() {
    const runner = new MobileChecklistRunner();
    await runner.runAll();
    const summary = runner.getSummary();

    console.log('\n========== MOBILE-FIRST CHECKLIST SUMMARY ==========');
    console.log(`Total: ${summary.total} | Passed: ${summary.passed} | Failed: ${summary.failed} | Score: ${summary.percentage}`);

    if (summary.failed > 0) {
        console.log('\nFailed items require attention:');
        summary.failedItems.forEach(item => {
            console.log(`  ${item.id}: ${item.check}`);
            console.log(`    -> ${item.fix}`);
        });
    }

    // Generate report file
    const report = runner.generateReport();
    console.log('\nReport generated: mobile-checklist-report.md');

    // Fail the build if critical items fail
    const criticalIds = ['VP-01', 'IN-01', 'PF-01', 'PF-03', 'AX-01', 'AX-02'];
    const criticalFailures = summary.failedItems.filter(i => criticalIds.includes(i.id));
    if (criticalFailures.length > 0) {
        console.error(`\nBUILD FAILED: ${criticalFailures.length} critical checks failed.`);
        process.exit(1);
    }
}

Expected output: The runner executes all checklist items, logs pass/fail per item with fix instructions, generates a summary with pass percentage, creates a markdown report file, and fails the build if any critical checks fail.

Common Mistakes

  1. Checklist not automated — A manual checklist that nobody runs is worthless. Integrate checks into CI/CD pipelines.
  2. Testing only on one device — Every device behaves differently. Test on iOS Safari, Android Chrome, and Samsung Internet at minimum.
  3. Ignoring performance checks — Performance is not a feature; it is a requirement. Fail the build if LCP exceeds 2.5s or bundle size exceeds 150KB.
  4. No touch target verification — Touch targets that pass visual review may fail on actual devices with larger fingers. Use automated size checks.
  5. Not testing with real user data — Synthetic testing misses real-world conditions. Use Real User Monitoring (RUM) to validate checklist items in production.
  6. Checklist never updated — Mobile best practices evolve. Review and update the checklist every 6 months.
  7. No actionable fix guidance — A checklist that only tells you what is broken (not how to fix it) creates frustration. Every check must include a concrete fix.

Practice Questions

  1. What are the 5 categories in the mobile-first checklist? Viewport and Layout, Touch and Interaction, Performance, Accessibility, Content and Typography.
  2. What is the minimum touch target size? 44x44px according to WCAG 2.5.5. Some guidelines suggest 48x48px for better usability.
  3. What CSS property eliminates the 300ms tap delay? touch-action: manipulation prevents the browser from waiting for a double-tap gesture.
  4. What are the three Core Web Vitals metrics? LCP (under 2.5s), FID (under 100ms), CLS (under 0.1).
  5. How do you check if all images have alt text programmatically? Query all img elements and verify each has the alt attribute: document.querySelectorAll('img').every(img => img.hasAttribute('alt')).

Challenge

Build a mobile-readiness dashboard that: (1) runs all 25 checklist items when a URL is entered, (2) displays pass/fail status with color-coded indicators (green/red), (3) shows a progress bar with percentage score, (4) lists failed items with the recommended fix, (5) captures a screenshot of the page at 375px viewport width, (6) measures LCP, CLS, and TTFB and displays them, (7) generates a downloadable PDF report, (8) allows re-running individual categories without re-running the full audit.

FAQ

How often should I run the mobile-first checklist?

Run it on every pull request (CI) for automatic checks. Run the full visual audit before every release. Review and update the checklist itself every 6 months.

What tools can automate these checks?

Lighthouse CI for performance and accessibility. Playwright/Puppeteer for interaction checks. axe-core for accessibility. Custom scripts for touch target and viewport checks.

Do I need to pass all 25 checks?

Critical checks (viewport meta, touch targets, LCP, CLS, alt text, form labels) are non-negotiable. Non-critical checks can have exceptions documented in a .checklistrc file.

How do I handle checklist exceptions?

Create a .checklistrc file that lists allowed exceptions with reasons. The audit script skips items in the exception list but logs them in the report.

Is there a lightweight version for rapid checks?

Yes. Run the 10 critical checks for CI (VP-01, VP-02, IN-01, IN-02, PF-01, PF-03, PF-05, AX-01, AX-02, CT-01). Run the full 25 checks before major releases.

Mini Project

Build a complete mobile-first audit tool that implements all checklist items from this lesson. The tool should: (1) open a given URL in a headless browser, (2) set the viewport to 375x812 (iPhone X dimensions), (3) run all 25 checklist items with automated tests, (4) generate a pass/fail report with fix recommendations, (5) capture screenshots at 320px, 375px, and 414px widths, (6) measure and display Core Web Vitals, (7) check the page on simulated 3G network, (8) export a markdown or HTML report, (9) support a --ci flag that exits with code 1 if critical checks fail, (10) support a --exceptions flag to skip specific checks.

What's Next

You have completed the Mobile-First Web Development course. Continue with Frontend Accessibility Guide to learn how to make your mobile-first sites accessible to all users, or review Advanced Responsive Design Patterns for complex responsive layout techniques.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro