이 글은 누구를 위한 것인가
- 스와이프, 드래그, 핀치 등 제스처 UX를 설계하려는 팀
- 터치 친화적 인터페이스를 구현하려는 프론트엔드 개발자
- 모바일 인터랙션 패턴을 체계적으로 적용하려는 팀
들어가며
마우스 클릭은 정확하지만 손가락은 넓다. 터치 타겟이 너무 작으면 실수가 늘어나고, 제스처 피드백이 없으면 "버튼이 눌렸나?" 의문이 든다. 모바일 UX는 손가락의 특성을 고려한 설계가 필요하다.
이 글은 bluefoxdev.kr의 제스처 터치 인터랙션 UX 가이드 를 참고하여 작성했습니다.
1. 터치 인터랙션 설계 원칙
[터치 타겟 크기]
Apple HIG: 최소 44×44pt
Google Material: 최소 48×48dp
WCAG 2.5.5: 최소 44×44px (AAA)
버튼이 작아도 터치 영역은 크게:
.small-icon { padding: 12px; } /* 아이콘 16px + 패딩 = 40px */
[제스처 피드백]
시각적: 색상 변화, 스케일 (transform: scale(0.95))
촉각: navigator.vibrate([10]) (Android)
애니메이션: 리플 효과, 이동 트랙
[스와이프 임계값]
의도적 스와이프: 50px 이상 이동
빠른 스와이프(flick): velocity > 0.3 px/ms
취소 구간: 30% 이하 이동 후 손 떼면 취소
[제스처 충돌 방지]
수직 스크롤 vs 가로 스와이프:
- 처음 이동 방향 감지 후 고정
touch-action: pan-y (가로 제스처만 처리)
touch-action: none (모두 JS로 처리)
2. 제스처 인터랙션 구현
import { useRef, useState } from 'react';
// 스와이프 삭제 컴포넌트
function SwipeToDelete({ children, onDelete }: { children: React.ReactNode; onDelete: () => void }) {
const [offset, setOffset] = useState(0);
const startX = useRef(0);
const isDragging = useRef(false);
const threshold = 80; // 삭제 임계값 (px)
const handlePointerDown = (e: React.PointerEvent) => {
startX.current = e.clientX;
isDragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging.current) return;
const diff = e.clientX - startX.current;
// 왼쪽으로만 스와이프
setOffset(Math.min(0, diff));
};
const handlePointerUp = () => {
isDragging.current = false;
if (Math.abs(offset) >= threshold) {
// 삭제 애니메이션 후 콜백
setOffset(-window.innerWidth);
setTimeout(onDelete, 300);
} else {
setOffset(0); // 원위치
}
};
return (
<div style={{ position: 'relative', overflow: 'hidden' }}>
{/* 삭제 배경 */}
<div style={{
position: 'absolute', inset: 0, background: '#ef4444',
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
paddingRight: 20, color: 'white', fontWeight: 600,
}}>
삭제
</div>
{/* 컨텐츠 */}
<div
style={{ transform: `translateX(${offset}px)`, transition: isDragging.current ? 'none' : 'transform 0.3s ease', touchAction: 'pan-y' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{children}
</div>
</div>
);
}
// 당겨서 새로고침 (Pull to Refresh)
function PullToRefresh({ onRefresh, children }: { onRefresh: () => Promise<void>; children: React.ReactNode }) {
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0);
const threshold = 80;
const handleTouchStart = (e: React.TouchEvent) => {
if (window.scrollY === 0) startY.current = e.touches[0].clientY;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (window.scrollY > 0) return;
const distance = e.touches[0].clientY - startY.current;
if (distance > 0) setPullDistance(Math.min(distance * 0.5, 120));
};
const handleTouchEnd = async () => {
if (pullDistance >= threshold) {
setIsRefreshing(true);
await onRefresh();
setIsRefreshing(false);
}
setPullDistance(0);
};
return (
<div onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
<div style={{ height: pullDistance, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', transition: isRefreshing ? 'none' : 'height 0.3s' }}>
{isRefreshing ? '새로고침 중...' : pullDistance >= threshold ? '손을 떼세요' : '당겨서 새로고침'}
</div>
{children}
</div>
);
}
마무리
터치 UX의 핵심은 "적절한 크기, 명확한 피드백, 예측 가능한 동작"이다. 터치 타겟은 최소 44px, 스와이프 임계값은 충분히 크게 설정해 실수 삭제를 방지한다. Pointer Events API는 마우스/터치/펜을 통합 처리해 코드 중복을 줄인다.