이 글은 누구를 위한 것인가
- 스와이프, 드래그, 핀치 등 제스처 UX를 설계하려는 팀
- 터치 친화적 인터페이스를 구현하려는 프론트엔드 개발자
- 모바일 인터랙션 패턴을 체계적으로 적용하려는 팀
들어가며
마우스 클릭은 정확하지만 손가락은 넓다. 터치 타겟이 너무 작으면 실수가 늘어나고(타겟 크기 연구, WCAG 2.2 최소 타겟 크기), 제스처 피드백이 없으면 "버튼이 눌렸나?" 의문이 든다. 모바일 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는 마우스/터치/펜을 통합 처리해 코드 중복을 줄인다.