Files
wow-design-wiki/mobile/mobile-interaction-patterns.md
T
neo 083f6ba783 feat(mobile): comprehensive mobile web design patterns — 6 new articles
- mobile-patterns.md: Mobile layout & pattern systems (18.5KB)
  Fluid typography, container queries, safe-area, CSS architecture,
  touch-optimized components, iOS/Android specifics, testing checklist

- mobile-layout-patterns.md: Mobile layout systems (11.5KB)
  CSS grid, single-column stack, full-bleed hero, card stack,
  swipeable carousel, split-screen, off-canvas nav, bottom sheet,
  FAB, component patterns

- mobile-navigation-patterns.md: Mobile navigation (9.8KB)
  Bottom tab bar, top nav, hamburger menu, bottom sheet nav,
  pill nav, comparison matrix, navigation rules, swipe navigation,
  breadcrumbs, tab content

- mobile-form-patterns.md: Mobile form patterns (10.3KB)
  Input types, keyboards, floating labels, validation patterns,
  progressive disclosure, autocomplete, masked inputs, toggle/radio,
  form anti-patterns

- mobile-interaction-patterns.md: Touch & gestures (16.7KB)
  Swipe detection, long press, pull-to-refresh, touch feedback,
  scroll-reveal, parallax, sticky scroll, CSS scroll animations,
  touch carousel, haptic feedback, passive listeners

- mobile-performance-patterns.md: Performance optimization (12.3KB)
  Core Web Vitals (LCP/INP/CLS), LCP optimization, INP debugging,
  CLS prevention, image optimization, lazy loading, service workers,
  CSS/JS optimization, network optimization

- mobile-landing-patterns.md: Landing page patterns (21.3KB)
  Section sequencing, hero, social proof, feature cards,
  CTA patterns (inline/sticky/full-bleed), testimonials, pricing,
  FAQ accordion, footer, mobile checklist
2026-07-03 20:15:10 +05:00

16 KiB

title, created, updated, type, tags, sources, confidence
title created updated type tags sources confidence
Mobile Interaction Patterns 2026-07-03 2026-07-03 system
mobile
interaction
gesture
touch
swipe
haptics
animation
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Transitions/Using_CSS_transitions
https://developer.mozilla.org/en-US/docs/Web/API/Touch_events
https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations/Using_CSS_animations
high

Mobile Interaction Patterns — Жесты, тач, анимации

1. Touch Events & Gestures

1.1 Basic Touch Handling

// Touch start — feedback at press
element.addEventListener('touchstart', (e) => {
  const touch = e.touches[0];
  // Handle initial touch
}, { passive: true });

// Touch move — dragging
element.addEventListener('touchmove', (e) => {
  e.preventDefault(); // Prevent scroll during drag
}, { passive: false });

// Touch end — release
element.addEventListener('touchend', (e) => {
  // Handle release
});

1.2 Swipe Detection

class SwipeHandler {
  constructor(element, options = {}) {
    this.element = element;
    this.threshold = options.threshold || 50;
    this.velocityThreshold = options.velocityThreshold || 0.3;
    this.direction = options.direction || 'horizontal';
    this.onSwipe = options.onSwipe;
    
    this.startX = 0;
    this.startY = 0;
    this.startTime = 0;
    this.isSwiping = false;
    
    this.bindEvents();
  }
  
  bindEvents() {
    this.element.addEventListener('touchstart', (e) => {
      this.startX = e.touches[0].clientX;
      this.startY = e.touches[0].clientY;
      this.startTime = Date.now();
      this.isSwiping = true;
    }, { passive: true });
    
    this.element.addEventListener('touchend', (e) => {
      if (!this.isSwiping) return;
      this.isSwiping = false;
      
      const endX = e.changedTouches[0].clientX;
      const endY = e.changedTouches[0].clientY;
      const endTime = Date.now();
      
      const diffX = this.startX - endX;
      const diffY = this.startY - endY;
      const timeDiff = endTime - this.startTime;
      const velocity = Math.abs(diffX) / timeDiff;
      
      // Check direction and distance
      if (this.direction === 'horizontal' && Math.abs(diffX) > this.threshold) {
        if (velocity > this.velocityThreshold) {
          const direction = diffX > 0 ? 'left' : 'right';
          this.onSwipe?.(direction, diffX, diffY);
        }
      } else if (this.direction === 'vertical' && Math.abs(diffY) > this.threshold) {
        if (velocity > this.velocityThreshold) {
          const direction = diffY > 0 ? 'up' : 'down';
          this.onSwipe?.(direction, diffX, diffY);
        }
      }
    }, { passive: true });
  }
}

// Usage
new SwipeHandler(container, {
  threshold: 50,
  velocityThreshold: 0.3,
  onSwipe: (direction, dx, dy) => {
    console.log(`Swiped ${direction} (${dx}, ${dy})`);
  }
});

1.3 Long Press / Double Tap

class LongPressHandler {
  constructor(element, onLongPress, onDoubleTap) {
    this.element = element;
    this.longPressThreshold = 500;
    this.doubleTapThreshold = 300;
    this.timeout = null;
    this.tapCount = 0;
    this.tapTimer = null;
    
    this.bindEvents();
  }
  
  bindEvents() {
    this.element.addEventListener('touchstart', (e) => {
      // Long press
      this.timeout = setTimeout(() => {
        this.longPress?.(e.touches[0]);
      }, this.longPressThreshold);
      
      // Double tap
      this.tapCount++;
      clearTimeout(this.tapTimer);
      this.tapTimer = setTimeout(() => {
        if (this.tapCount === 2) {
          this.doubleTap?.(e.changedTouches[0]);
        }
        this.tapCount = 0;
      }, this.doubleTapThreshold);
    });
    
    this.element.addEventListener('touchend', () => {
      clearTimeout(this.timeout);
    });
  }
}

2. Pull to Refresh

2.1 CSS + JS Pull to Refresh

<div class="pull-to-refresh">
  <div class="ptr-spinner">
    <div class="spinner"></div>
  </div>
  <div class="ptr-label">Обновление...</div>
</div>
<div class="content">
  <!-- Page content -->
</div>
.pull-to-refresh {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 60px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transform: translateY(-100%);
  transition: transform 0.3s ease;
  z-index: 100;
}

.pull-to-refresh.active {
  transform: translateY(0);
}

.ptr-spinner {
  width: 24px;
  height: 24px;
  border: 3px solid var(--border-1);
  border-top-color: var(--primary);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

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

.ptr-label {
  font-size: var(--text-sm);
  color: var(--text-secondary);
}

/* Scroll container */
.scroll-container {
  padding-top: 60px;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
class PullToRefresh {
  constructor(element, onRefresh) {
    this.element = element;
    this.content = element.querySelector('.scroll-container');
    this.refresh = element.querySelector('.pull-to-refresh');
    this.onRefresh = onRefresh;
    this.startY = 0;
    this.currentY = 0;
    this.threshold = 80;
    
    this.bindEvents();
  }
  
  bindEvents() {
    this.content.addEventListener('touchstart', (e) => {
      if (this.content.scrollTop === 0) {
        this.startY = e.touches[0].clientY;
      }
    }, { passive: true });
    
    this.content.addEventListener('touchmove', (e) => {
      if (this.content.scrollTop === 0) {
        this.currentY = e.touches[0].clientY;
        const diff = this.currentY - this.startY;
        
        if (diff > 0) {
          e.preventDefault();
          this.refresh.style.transform = `translateY(${Math.min(diff * 0.5, this.threshold)}px)`;
          
          if (diff >= this.threshold) {
            this.refresh.classList.add('active');
          }
        }
      }
    }, { passive: false });
    
    this.content.addEventListener('touchend', () => {
      if (this.refresh.classList.contains('active')) {
        this.refresh.style.transform = 'translateY(0)';
        this.refresh.classList.add('active');
        this.onRefresh?.(() => {
          this.refresh.classList.remove('active');
        });
      }
    });
  }
}

// Usage
new PullToRefresh(document.body, (done) => {
  fetch('/api/data')
    .then(r => r.json())
    .then(data => {
      updateContent(data);
      done();
    });
});

3. Touch Feedback Patterns

3.1 Active State (Ripple-like)

.touch-feedback {
  position: relative;
  overflow: hidden;
  transition: transform 0.1s ease, opacity 0.1s ease;
  -webkit-tap-highlight-color: transparent;
}

.touch-feedback:active {
  transform: scale(0.97);
  opacity: 0.8;
}

/* Subtle press effect */
.touch-feedback-press {
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.touch-feedback-press:active {
  transform: scale(0.98);
  box-shadow: 0 0 0 2px var(--primary);
}

3.2 Haptic Feedback

// Web Vibration API (Android + some iOS)
function vibrate(pattern = [50]) {
  if (navigator.vibrate) {
    navigator.vibrate(pattern);
  }
}

// Haptic patterns for different actions
const haptics = {
  light: [16],
  medium: [30],
  heavy: [50],
  success: [30, 50, 30],
  error: [100, 50, 100],
  selection: [10],
  click: [15]
};

// Usage
function handleTouch(element) {
  element.addEventListener('touchend', () => {
    vibrate(haptics.click);
  });
}

4. Scroll-Linked Animations

4.1 Scroll Reveal

const revealObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('revealed');
      revealObserver.unobserve(entry.target);
    }
  });
}, {
  threshold: 0.1,
  rootMargin: '0px 0px -50px 0px'
});

document.querySelectorAll('.reveal').forEach(el => {
  revealObserver.observe(el);
});
.reveal {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.reveal.revealed {
  opacity: 1;
  transform: translateY(0);
}

/* Staggered reveal for lists */
.reveal:nth-child(1) { transition-delay: 0ms; }
.reveal:nth-child(2) { transition-delay: 50ms; }
.reveal:nth-child(3) { transition-delay: 100ms; }
.reveal:nth-child(4) { transition-delay: 150ms; }
.reveal:nth-child(5) { transition-delay: 200ms; }

4.2 Parallax Scroll

.parallax-section {
  position: relative;
  overflow: hidden;
  height: 60vh;
}

.parallax-bg {
  position: absolute;
  top: -20%;
  left: 0;
  width: 100%;
  height: 140%;
  background-size: cover;
  will-change: transform;
}

.parallax-content {
  position: relative;
  z-index: 2;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
function handleParallax() {
  const sections = document.querySelectorAll('.parallax-section');
  const scrollY = window.scrollY;
  
  sections.forEach(section => {
    const rect = section.getBoundingClientRect();
    const offset = rect.top * 0.3;
    const bg = section.querySelector('.parallax-bg');
    if (bg) {
      bg.style.transform = `translateY(${offset}px)`;
    }
  });
}

window.addEventListener('scroll', handleParallax, { passive: true });

4.3 Sticky Scroll Sections

.sticky-scroll-container {
  display: flex;
  height: 300vh;
}

.sticky-panel {
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Sequential reveal */
.sticky-panel:nth-child(1) { background: #f0f0f0; z-index: 1; }
.sticky-panel:nth-child(2) { background: #e0e0e0; z-index: 2; }
.sticky-panel:nth-child(3) { background: #d0d0d0; z-index: 3; }

5. Gesture Navigation

5.1 Swipe Back

class SwipeBack {
  constructor(element, onSwipeBack) {
    this.element = element;
    this.onSwipeBack = onSwipeBack;
    this.startX = 0;
    this.threshold = 100;
    
    this.bindEvents();
  }
  
  bindEvents() {
    this.element.addEventListener('touchstart', (e) => {
      this.startX = e.touches[0].clientX;
    }, { passive: true });
    
    this.element.addEventListener('touchmove', (e) => {
      const diff = e.touches[0].clientX - this.startX;
      if (diff > 0 && window.scrollX === 0) {
        e.preventDefault();
        // Visual feedback
        this.element.style.transform = `translateX(${diff}px)`;
        this.element.style.opacity = 1 - (diff / this.threshold);
      }
    }, { passive: false });
    
    this.element.addEventListener('touchend', (e) => {
      const diff = e.changedTouches[0].clientX - this.startX;
      if (diff > this.threshold) {
        this.onSwipeBack?.();
      }
      this.element.style.transform = '';
      this.element.style.opacity = '';
    });
  }
}

5.2 Horizontal Card Swipe

class HorizontalSwipe {
  constructor(element, options = {}) {
    this.element = element;
    this.cards = element.querySelectorAll('.card');
    this.currentIndex = 0;
    this.threshold = 100;
    this.onSwipe = options.onSwipe;
    
    this.bindEvents();
  }
  
  bindEvents() {
    this.cards.forEach(card => {
      card.addEventListener('touchstart', (e) => {
        this.startX = e.touches[0].clientX;
      }, { passive: true });
      
      card.addEventListener('touchend', (e) => {
        const diff = e.changedTouches[0].clientX - this.startX;
        if (Math.abs(diff) > this.threshold) {
          this.currentIndex += diff > 0 ? 1 : -1;
          this.currentIndex = Math.max(0, Math.min(this.currentIndex, this.cards.length - 1));
          this.onSwipe?.(this.currentIndex, diff > 0 ? 'next' : 'prev');
        }
      });
    });
  }
}

6. Scroll-Linked CSS Animations

6.1 Scroll-driven Animations (Modern)

@keyframes fade-in-up {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-on-scroll {
  animation: fade-in-up linear;
  animation-timeline: view();
  animation-range: entry 0% entry 25%;
}

/* Progress bar that fills on scroll */
.progress-track {
  height: 4px;
  background: var(--surface-2);
  border-radius: 2px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  background: var(--primary);
  border-radius: 2px;
  animation: progress linear;
  animation-timeline: scroll();
  animation-range: 0 100vh;
}

@keyframes progress {
  to { width: 100%; }
}

6.2 Scroll-linked Typography

.scroll-text {
  font-size: clamp(1rem, 1rem + 20cqw, 3rem);
  transition: font-size 0.1s ease;
}

/* Scale with scroll position */
.scroll-scale {
  transform: scale(calc(1 + scroll(target, transform) * 0.5));
  animation: scroll-scale linear;
  animation-timeline: scroll();
}
class TouchCarousel {
  constructor(element, options = {}) {
    this.element = element;
    this.slides = element.querySelectorAll('.slide');
    this.currentIndex = 0;
    this.startX = 0;
    this.currentX = 0;
    this.isDragging = false;
    this.threshold = 50;
    this.autoplay = options.autoplay ?? true;
    this.interval = options.interval ?? 5000;
    this.timer = null;
    
    this.bindEvents();
    this.startAutoplay();
  }
  
  bindEvents() {
    // Touch
    this.element.addEventListener('touchstart', (e) => {
      this.stopAutoplay();
      this.startX = e.touches[0].clientX;
      this.isDragging = true;
      this.element.style.transition = 'none';
    }, { passive: true });
    
    this.element.addEventListener('touchmove', (e) => {
      if (!this.isDragging) return;
      this.currentX = e.touches[0].clientX;
      const diff = this.currentX - this.startX;
      this.slides[this.currentIndex].style.transform = `translateX(${diff}px)`;
    }, { passive: true });
    
    this.element.addEventListener('touchend', () => {
      this.isDragging = false;
      this.element.style.transition = 'transform 0.3s ease';
      const diff = this.currentX - this.startX;
      if (Math.abs(diff) > this.threshold) {
        this.goTo(diff > 0 ? this.currentIndex - 1 : this.currentIndex + 1);
      } else {
        this.goTo(this.currentIndex);
      }
      this.startAutoplay();
    });
    
    // Indicator dots
    this.dots = element.querySelectorAll('.dot');
    this.dots.forEach((dot, i) => {
      dot.addEventListener('click', () => this.goTo(i));
    });
  }
  
  goTo(index) {
    this.currentIndex = Math.max(0, Math.min(index, this.slides.length - 1));
    this.slides[this.currentIndex].style.transform = 'translateX(0)';
    this.updateDots();
  }
  
  updateDots() {
    this.dots.forEach((dot, i) => {
      dot.classList.toggle('active', i === this.currentIndex);
    });
  }
  
  startAutoplay() {
    this.timer = setInterval(() => {
      this.goTo((this.currentIndex + 1) % this.slides.length);
    }, this.interval);
  }
  
  stopAutoplay() {
    clearInterval(this.timer);
  }
}

8. Performance Tips for Mobile Touch

8.1 Passive Event Listeners

// Always use passive for scroll-related listeners
document.addEventListener('scroll', handler, { passive: true });
document.addEventListener('touchstart', handler, { passive: true });

8.2 Debounce Scroll

function debounceScroll(fn, delay = 16) {
  let ticking = false;
  return () => {
    if (!ticking) {
      requestAnimationFrame(() => {
        fn();
        ticking = false;
      });
      ticking = true;
    }
  };
}

window.addEventListener('scroll', debounceScroll(() => {
  // Handle scroll
}, 16));

8.3 GPU-Accelerated Animations

/* Always use transform + opacity for animations */
.animated-element {
  will-change: transform, opacity;
  transform: translateZ(0); /* Force GPU layer */
}

/* Avoid animating: width, height, top, left, margin */

9. Mobile Interaction Anti-Patterns

❌ Плохо ✅ Хорошо
Hover-эффекты Active/pressed states
Автоплей видео Play по тапу пользователя
Скрытые жесты Визуальные подсказки для жестов
100vh секции 100dvh (dynamic viewport)
Тяжелые scroll-анимации CSS scroll-driven animations
Пасивные листенеры Passive + debounce
Только визуальный фидбэк Haptic + visual feedback