Skip to content

Mobile-First Lists — Complete Guide

DodaTech Updated 2026-06-28 11 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 lists use adequate spacing, clear visual hierarchy, touch feedback, swipe actions, and responsive layouts for readable and interactive lists on small screens.

What You'll Learn

  • List item anatomy and spacing
  • Single vs multi-line list items
  • Touch feedback and press states
  • Swipe-to-delete and swipe actions
  • Pull-to-refresh pattern
  • Infinite scroll and pagination
  • Accessible list markup

Why It Matters

  • Lists are the most common mobile content pattern
  • Poor spacing causes wrong tap targets
  • Missing swipe actions frustrates power users
  • Slow list loading degrades the experience

Real-World Use

  • An email inbox with swipe-to-archive
  • A contact list with alphabet index
  • A notification feed with timestamps
  • A settings screen with toggle rows
flowchart LR
  A[Mobile-First Lists] --> B[Anatomy]
  A --> C[Touch]
  A --> D[Gestures]
  A --> E[Loading]
  B --> F[Spacing + hierarchy]
  C --> G[Press states]
  D --> H[Swipe actions]
  E --> I[Infinite scroll]

List Item Anatomy

Every list item needs proper spacing, clear visual hierarchy, and adequate touch targets.

Code Example: Basic List

<ul class="mobile-list">
    <li class="list-item">
        <div class="list-avatar">
            <img src="https://placehold.co/48x48/3b82f6/ffffff?text=AC" alt="Alice Chen" width="48" height="48" loading="lazy">
        </div>
        <div class="list-content">
            <div class="list-title">Alice Chen</div>
            <div class="list-subtitle">Senior Developer at DodaTech</div>
            <div class="list-meta">Last active 2 hours ago</div>
        </div>
        <div class="list-action">
            <span class="list-badge">3</span>
        </div>
    </li>

    <li class="list-item">
        <div class="list-avatar list-avatar-fallback">BM</div>
        <div class="list-content">
            <div class="list-title">Bob Martinez</div>
            <div class="list-subtitle">UI Designer</div>
            <div class="list-meta">Last active yesterday</div>
        </div>
        <div class="list-action">
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M9 18l6-6-6-6"/>
            </svg>
        </div>
    </li>

    <li class="list-item">
        <div class="list-icon">
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
            </svg>
        </div>
        <div class="list-content">
            <div class="list-title">Notifications</div>
            <div class="list-subtitle">5 unread messages</div>
        </div>
        <div class="list-action">
            <label class="list-toggle">
                <input type="checkbox" checked>
                <span class="toggle-track"></span>
            </label>
        </div>
    </li>
</ul>

<style>
.mobile-list {
    list-style: none;
    padding: 0;
    margin: 0;
    max-width: 480px;
    border: 1px solid #e5e7eb;
    border-radius: 12px;
    overflow: hidden;
}

.list-item {
    display: flex;
    align-items: center;
    gap: 0.875rem;
    padding: 0.875rem 1rem;
    min-height: 56px;
    border-bottom: 1px solid #e5e7eb;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: background 0.15s ease;
}

.list-item:last-child {
    border-bottom: none;
}

.list-item:active {
    background: #f9fafb;
}

.list-avatar {
    width: 48px;
    height: 48px;
    border-radius: 50%;
    overflow: hidden;
    flex-shrink: 0;
}

.list-avatar img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.list-avatar-fallback {
    display: flex;
    align-items: center;
    justify-content: center;
    background: #3b82f6;
    color: #fff;
    font-weight: 600;
    font-size: 1rem;
}

.list-icon {
    width: 48px;
    height: 48px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #6b7280;
    flex-shrink: 0;
}

.list-content {
    flex: 1;
    min-width: 0;
}

.list-title {
    font-size: 0.9375rem;
    font-weight: 600;
    color: #1f2937;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.list-subtitle {
    font-size: 0.8125rem;
    color: #6b7280;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.list-meta {
    font-size: 0.75rem;
    color: #9ca3af;
    margin-top: 0.125rem;
}

.list-action {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    color: #9ca3af;
}

.list-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 22px;
    height: 22px;
    padding: 0 6px;
    font-size: 0.75rem;
    font-weight: 700;
    color: #fff;
    background: #ef4444;
    border-radius: 11px;
}

.toggle-track {
    width: 44px;
    height: 24px;
    background: #d1d5db;
    border-radius: 12px;
    position: relative;
    display: block;
    transition: background 0.2s;
}

.toggle-track::after {
    content: '';
    position: absolute;
    top: 2px;
    left: 2px;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: #fff;
    transition: transform 0.2s;
}

.list-toggle input {
    display: none;
}

.list-toggle input:checked + .toggle-track {
    background: #3b82f6;
}

.list-toggle input:checked + .toggle-track::after {
    transform: translateX(20px);
}
</style>

Expected output: A styled list with avatar, content, and action sections. Each item has adequate touch spacing (56px minimum), press feedback, and proper truncation for long text.

Swipe Actions

Swipe-to-reveal actions are a mobile-native pattern for quick operations.

Code Example: Swipe-to-Delete

<div class="swipe-list" id="swipe-list">
    <div class="swipe-container">
        <div class="swipe-reveal delete-action">Delete</div>
        <div class="swipe-reveal archive-action">Archive</div>
        <div class="swipe-item" data-id="1">
            <div class="list-avatar">
                <img src="https://placehold.co/48x48/3b82f6/ffffff?text=ER" alt="Email" width="48" height="48" loading="lazy">
            </div>
            <div class="list-content">
                <div class="list-title">Welcome to DodaTech</div>
                <div class="list-subtitle">Your account has been created successfully</div>
            </div>
            <span class="list-meta-time">2m ago</span>
        </div>
    </div>

    <div class="swipe-container">
        <div class="swipe-reveal delete-action">Delete</div>
        <div class="swipe-reveal archive-action">Archive</div>
        <div class="swipe-item" data-id="2">
            <div class="list-avatar">
                <img src="https://placehold.co/48x48/16a34a/ffffff?text=PM" alt="Project" width="48" height="48" loading="lazy">
            </div>
            <div class="list-content">
                <div class="list-title">Project Deadline Extended</div>
                <div class="list-subtitle">The Q3 deadline has been moved to August 15</div>
            </div>
            <span class="list-meta-time">1h ago</span>
        </div>
    </div>
</div>

<script>
document.querySelectorAll('.swipe-container').forEach(container => {
    const item = container.querySelector('.swipe-item');
    let startX = 0;
    let currentX = 0;
    let isDragging = false;

    item.addEventListener('touchstart', function(e) {
        startX = e.touches[0].clientX;
        isDragging = true;
        this.style.transition = 'none';
    }, { passive: true });

    item.addEventListener('touchmove', function(e) {
        if (!isDragging) return;
        currentX = e.touches[0].clientX;
        const diff = currentX - startX;
        if (diff < 0) {
            this.style.transform = `translateX(${diff}px)`;
        }
    }, { passive: true });

    item.addEventListener('touchend', function(e) {
        isDragging = false;
        const diff = currentX - startX;
        this.style.transition = 'transform 0.3s ease';

        if (diff < -80) {
            this.style.transform = 'translateX(-160px)';
            this.dataset.open = 'true';
        } else {
            this.style.transform = 'translateX(0)';
            this.dataset.open = 'false';
        }
    }, { passive: true });

    // Close on tap of revealed action
    container.querySelectorAll('.swipe-reveal').forEach(btn => {
        btn.addEventListener('click', function() {
            const action = this.classList.contains('delete-action') ? 'deleted' : 'archived';
            const id = item.dataset.id;
            item.style.transform = 'translateX(-100%)';
            item.style.opacity = '0';
            setTimeout(() => {
                container.remove();
            }, 300);
            console.log(`${action} item ${id}`);
        });
    });
});

// Close all swipe actions on scroll
document.addEventListener('scroll', () => {
    document.querySelectorAll('.swipe-item[data-open="true"]').forEach(item => {
        item.style.transform = 'translateX(0)';
        item.dataset.open = 'false';
    });
}, { passive: true });
</script>

<style>
.swipe-list {
    max-width: 480px;
    border: 1px solid #e5e7eb;
    border-radius: 12px;
    overflow: hidden;
}

.swipe-container {
    position: relative;
    overflow: hidden;
}

.swipe-reveal {
    position: absolute;
    top: 0;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.875rem;
    font-weight: 600;
    color: #fff;
    cursor: pointer;
    z-index: 0;
    padding: 0 1.5rem;
    -webkit-tap-highlight-color: transparent;
}

.delete-action {
    right: 0;
    background: #ef4444;
    width: 80px;
}

.archive-action {
    right: 80px;
    background: #f59e0b;
    width: 80px;
}

.swipe-item {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 0.875rem;
    padding: 0.875rem 1rem;
    background: #fff;
    min-height: 56px;
    border-bottom: 1px solid #e5e7eb;
    user-select: none;
    touch-action: pan-y;
}

.swipe-container:last-child .swipe-item {
    border-bottom: none;
}

.list-meta-time {
    font-size: 0.75rem;
    color: #9ca3af;
    flex-shrink: 0;
}
</style>

Expected output: Swiping left on a list item reveals two action buttons (Archive and Delete). Tapping an action executes it with an animation. Scrolling the page closes any open swipe actions.

Pull-to-Refresh

Pull-to-refresh is a standard mobile pattern for updating list content.

Code Example: Pull-to-Refresh

<div class="pull-refresh-container" id="refresh-container">
    <div class="refresh-indicator" id="refresh-indicator">
        <div class="refresh-spinner"></div>
        <span class="refresh-text">Pull to refresh</span>
    </div>

    <ul class="mobile-list refresh-list" id="refresh-list">
        <li class="list-item">
            <div class="list-content">
                <div class="list-title">Item 1</div>
                <div class="list-subtitle">This is the first item</div>
            </div>
        </li>
        <li class="list-item">
            <div class="list-content">
                <div class="list-title">Item 2</div>
                <div class="list-subtitle">This is the second item</div>
            </div>
        </li>
        <li class="list-item">
            <div class="list-content">
                <div class="list-title">Item 3</div>
                <div class="list-subtitle">This is the third item</div>
            </div>
        </li>
    </ul>
</div>

<script>
const container = document.getElementById('refresh-container');
const indicator = document.getElementById('refresh-indicator');
const list = document.getElementById('refresh-list');
let startY = 0;
let pulling = false;

container.addEventListener('touchstart', function(e) {
    if (window.scrollY === 0) {
        startY = e.touches[0].clientY;
        pulling = true;
    }
}, { passive: true });

container.addEventListener('touchmove', function(e) {
    if (!pulling) return;
    const diff = e.touches[0].clientY - startY;
    if (diff > 0) {
        const pullDistance = Math.min(diff * 0.5, 80);
        list.style.transform = `translateY(${pullDistance}px)`;
        indicator.style.opacity = pullDistance / 80;

        if (pullDistance > 60) {
            indicator.querySelector('.refresh-text').textContent = 'Release to refresh';
        } else {
            indicator.querySelector('.refresh-text').textContent = 'Pull to refresh';
        }
    }
}, { passive: true });

container.addEventListener('touchend', function() {
    if (!pulling) return;
    pulling = false;
    const transform = list.style.transform;
    const pullDistance = parseInt(transform.replace('translateY(', '')) || 0;

    if (pullDistance > 60) {
        indicator.querySelector('.refresh-text').textContent = 'Refreshing...';
        indicator.querySelector('.refresh-spinner').style.animation = 'spin 0.6s linear infinite';
        list.style.transform = 'translateY(60px)';
        list.style.transition = 'transform 0.3s ease';

        setTimeout(() => {
            list.style.transform = 'translateY(0)';
            list.style.transition = 'transform 0.3s ease';
            indicator.querySelector('.refresh-text').textContent = 'Pull to refresh';
            indicator.querySelector('.refresh-spinner').style.animation = '';
            setTimeout(() => {
                list.style.transition = '';
            }, 300);
        }, 1500);
    } else {
        list.style.transform = 'translateY(0)';
        list.style.transition = 'transform 0.3s ease';
        setTimeout(() => { list.style.transition = ''; }, 300);
    }
}, { passive: true });
</script>

<style>
.pull-refresh-container {
    position: relative;
    max-width: 480px;
    overflow: hidden;
}

.refresh-indicator {
    position: absolute;
    top: -60px;
    left: 0;
    right: 0;
    height: 60px;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    opacity: 0;
    color: #6b7280;
    font-size: 0.875rem;
}

.refresh-spinner {
    width: 20px;
    height: 20px;
    border: 2px solid #e5e7eb;
    border-top-color: #3b82f6;
    border-radius: 50%;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

.refresh-list {
    transition: transform 0s;
}
</style>

Expected output: Pulling down at the top of the list reveals a refresh indicator. Releasing the pull past the threshold triggers a refresh animation. The list moves down and back up during the refresh cycle.

Infinite Scroll

Load more content as the user scrolls to the bottom of the list.

Code Example: Infinite Scroll

<div class="infinite-scroll-container" id="infinite-container">
    <ul class="mobile-list infinite-list" id="infinite-list">
        <!-- Items loaded dynamically -->
    </ul>
    <div class="load-more" id="load-more">
        <div class="refresh-spinner"></div>
        <span>Loading more items...</span>
    </div>
</div>

<script>
const list = document.getElementById('infinite-list');
const loader = document.getElementById('load-more');
let page = 1;
let loading = false;

function loadItems() {
    if (loading) return;
    loading = true;
    loader.style.display = 'flex';

    setTimeout(() => {
        for (let i = 0; i < 10; i++) {
            const li = document.createElement('li');
            li.className = 'list-item';
            li.innerHTML = `
                <div class="list-content">
                    <div class="list-title">Item ${(page - 1) * 10 + i + 1}</div>
                    <div class="list-subtitle">Loaded from page ${page}</div>
                </div>
            `;
            list.appendChild(li);
        }
        page++;
        loading = false;
        loader.style.display = 'none';

        if (page > 5) {
            loader.innerHTML = '<span>No more items to load</span>';
            observer.disconnect();
        }
    }, 800);
}

// Initial load
loadItems();

// Intersection Observer for infinite scroll
const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) {
        loadItems();
    }
}, { rootMargin: '200px' });

observer.observe(loader);
</script>

<style>
.infinite-scroll-container {
    max-width: 480px;
}

.load-more {
    display: none;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    padding: 1.5rem;
    font-size: 0.875rem;
    color: #6b7280;
}
</style>

Expected output: The list loads 10 items initially. When the user scrolls near the bottom, the loading indicator appears and more items load. After 50 items, a "No more items" message shows.

Common Mistakes

  1. Insufficient list item height — Items under 44px are hard to tap. 56-64px is comfortable for single-line lists; 72px+ for multi-line.
  2. No press feedback — Users cannot confirm their tap. Always include :active state or a ripple effect.
  3. Swipe actions without visual hint — Users do not know swiping is possible. Show a small hint on first load or use an icon.
  4. Infinite scroll without error handling — If the load fails, users see a stuck spinner with no way to retry.
  5. No alphabet index for long lists — Scrolling through 1000 contacts without an index is slow and frustrating.
  6. Pull-to-refresh on non-scrollable content — Pull-to-refresh only makes sense when the user is at the top of a scrollable list.
  7. Lists without virtualization — Rendering 10,000 DOM nodes kills mobile performance. Use virtual scrolling libraries.

Practice Questions

  1. What is the recommended minimum height for mobile list items? 56px for single-line items, 72px for multi-line items with subtitles.
  2. How do you implement swipe-to-delete? Track touchstart, touchmove, and touchend events. Translate the item horizontally based on finger movement. Reveal action buttons behind the item.
  3. What is the Intersection Observer Patternver" >}} pattern used for in lists? Detecting when the user scrolls near the bottom to trigger infinite scroll loading.
  4. How does pull-to-refresh work? Track vertical touch movement when the page is at scroll position 0. Translate the content down proportionally. On release past a threshold, trigger a refresh callback.
  5. What Accessibility attributes should a list have? Use ul with role="list" (implied), aria-label for the list purpose, and role="listitem" on each item. For interactive items, add role="button" and tabindex="0".

Challenge

Build a messaging inbox with 50 messages. Each message shows: sender avatar (48px), sender name, subject line, message preview (single line), timestamp, and an unread indicator dot. Implement: (1) swipe left to reveal Archive and Delete actions (with animation), (2) swipe right to mark as read/unread, (3) pull-to-refresh to simulate loading new messages, (4) infinite scroll loading 10 messages at a time, (5) an alphabet index for quick scrolling, (6) press feedback on all items, (7) a loading skeleton for the initial load.

FAQ

What is the ideal spacing between list items?

Use 1px bottom border between items with 12-16px horizontal padding. No extra margin between items within the same group. Use section headers to separate groups with 8px gap.

Should I use a flat list or sectioned list?

Use a flat list for homogeneous items (contacts, emails). Use sectioned lists with sticky headers for categorized content (settings, grouped contacts).

How do I handle long press (context menu) on list items?

Listen for a long press by tracking touchstart timestamp. If the finger stays in place for 500ms without moving, trigger the context menu. Provide visual feedback with a highlight.

What is the best pattern for filtering a list?

Use a sticky search bar at the top with real-time filtering. For complex filters, use a full-screen filter panel (see Lesson 10) that returns results to the list.

{{< faq "How do I make a list accessible for keyboard users?" "Ensure all list items are focusable with tabindex=\"0\" if interactive. Use role=\"listbox\" with aria-selected for selectable lists. Support arrow key navigation between items." >}}

Mini Project

Build a task management app with three sections: Today, Tomorrow, Upcoming. Each task shows: a checkbox (44x44px), task title, due date, priority badge (High/Medium/Low), and a swipe-to-complete action. Implement: (1) pull-to-refresh to reload tasks from a simulated API, (2) swipe left to delete with confirmation, (3) swipe right to mark complete with strikethrough animation, (4) long press to reorder, (5) alphabet index for the Upcoming section (if many tasks), (6) infinite scroll for the Upcoming section, (7) empty state illustration when no tasks exist.

What's Next

Continue with Lesson 13: Mobile-First Footer to design footers optimized for mobile screens.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro