- 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
705 lines
16 KiB
Markdown
705 lines
16 KiB
Markdown
---
|
|
title: Mobile Interaction Patterns
|
|
created: 2026-07-03
|
|
updated: 2026-07-03
|
|
type: system
|
|
tags: [mobile, interaction, gesture, touch, swipe, haptics, animation]
|
|
sources:
|
|
- 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
|
|
confidence: high
|
|
---
|
|
|
|
# Mobile Interaction Patterns — Жесты, тач, анимации
|
|
|
|
## 1. Touch Events & Gestures
|
|
|
|
### 1.1 Basic Touch Handling
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```html
|
|
<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>
|
|
```
|
|
|
|
```css
|
|
.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;
|
|
}
|
|
```
|
|
|
|
```javascript
|
|
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)
|
|
|
|
```css
|
|
.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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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);
|
|
});
|
|
```
|
|
|
|
```css
|
|
.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
|
|
|
|
```css
|
|
.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;
|
|
}
|
|
```
|
|
|
|
```javascript
|
|
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
|
|
|
|
```css
|
|
.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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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)
|
|
|
|
```css
|
|
@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
|
|
|
|
```css
|
|
.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();
|
|
}
|
|
```
|
|
|
|
## 7. Touch-Optimized Carousel
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
// Always use passive for scroll-related listeners
|
|
document.addEventListener('scroll', handler, { passive: true });
|
|
document.addEventListener('touchstart', handler, { passive: true });
|
|
```
|
|
|
|
### 8.2 Debounce Scroll
|
|
|
|
```javascript
|
|
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
|
|
|
|
```css
|
|
/* 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 |
|