이 글은 누구를 위한 것인가
- 앱과 웹의 인터랙션이 딱딱하게 느껴져 개선하고 싶은 디자이너
- 애니메이션을 코드로 구현하는 프론트엔드 개발자
- "작은 디테일이 큰 차이를 만든다"고 믿는 프로덕트 팀
들어가며
마이크로인터랙션은 눈에 잘 띄지 않지만, 없으면 바로 느껴진다. 버튼을 누를 때의 미세한 눌림, 좋아요를 누를 때의 하트 애니메이션, 파일 업로드가 완료될 때의 체크 표시 — 이런 것들이 쌓여 "이 앱은 뭔가 다르다"는 느낌을 만든다.
Dan Saffer는 『Microinteractions』에서 마이크로인터랙션을 트리거(Trigger) → 규칙(Rule) → 피드백(Feedback) → 루프(Loop) 의 4요소로 정의했다. 이 구조를 이해하면 어떤 인터랙션이든 설계할 수 있다.
이 글은 bluefoxdev.kr의 UI 인터랙션 패턴 을 참고하고, 애니메이션 구현 관점에서 확장하여 작성했습니다.
1. 마이크로인터랙션의 4요소
트리거 (Trigger)
└── 사용자 행동: 버튼 클릭, 스크롤, 호버
└── 시스템 이벤트: 알림 수신, 업로드 완료, 오류 발생
규칙 (Rule)
└── 트리거에 반응하는 방식 정의
└── 상태 변화 로직
피드백 (Feedback)
└── 시각적: 색상, 크기, 위치 변화
└── 청각적: 효과음 (신중히)
└── 촉각적: 햅틱 피드백
루프 (Loop)
└── 인터랙션 지속 시간과 반복 여부
└── 루프 종료 조건
2. 애니메이션 타이밍 원칙 (6가지)
원칙 1: 진입보다 퇴장이 빠르게
요소가 나타나는 것(250ms)보다 사라지는 것(150ms)이 빠르게 느껴져야 한다. 등장은 존재를 알리고, 퇴장은 방해가 되지 않아야 한다.
/* ✅ 좋음 */
.modal {
transition: opacity 250ms ease-out; /* 진입 */
}
.modal.exit {
transition: opacity 150ms ease-in; /* 퇴장 */
}
원칙 2: Easing 선택 기준
| 상황 | Easing | 이유 |
|---|---|---|
| 화면 진입 | ease-out | 빠르게 시작, 천천히 안착 |
| 화면 퇴장 | ease-in | 천천히 시작, 빠르게 사라짐 |
| 상태 전환 | ease-in-out | 부드러운 양방향 |
| 탄성 효과 | spring | 자연스러운 물리 기반 |
원칙 3: 적절한 지속 시간
즉각 피드백 (버튼 hover): 100~150ms
빠른 전환 (토글, 체크): 150~200ms
일반 전환 (모달, 드로어): 200~300ms
강조 애니메이션 (온보딩): 300~500ms
페이지 전환: 300~400ms
⚠️ 500ms 이상: 사용자가 지루하다고 느낌
원칙 4: 물리 법칙 모방
자연계에서 물체는 갑자기 시작하거나 멈추지 않는다. 스프링, 중력, 관성을 모방하면 자연스럽다.
// Framer Motion spring 설정
const springConfig = {
type: 'spring',
stiffness: 300, // 강도 (높을수록 빠름)
damping: 30, // 감쇠 (낮을수록 통통 튕김)
mass: 1,
};
원칙 5: 계층적 움직임
여러 요소가 동시에 움직이면 혼란스럽다. 주요 요소가 먼저, 부수 요소가 나중에 움직인다 (stagger).
// Framer Motion stagger
const containerVariants = {
visible: {
transition: { staggerChildren: 0.05 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
원칙 6: 모션 감소 설정 존중
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
3. 상태별 마이크로인터랙션 패턴 (9가지)
패턴 7: 버튼 클릭 피드백
.button {
transition: transform 100ms ease-out, background-color 150ms ease;
}
.button:active {
transform: scale(0.97);
}
.button:hover {
background-color: var(--color-primary-hover);
}
패턴 8: 좋아요/즐겨찾기 토글
import { motion, AnimatePresence } from 'framer-motion';
function LikeButton({ liked, onToggle }: Props) {
return (
<motion.button
onClick={onToggle}
whileTap={{ scale: 0.8 }}
>
<AnimatePresence mode="wait">
{liked ? (
<motion.span
key="liked"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: 'spring', stiffness: 400 }}
>
❤️
</motion.span>
) : (
<motion.span key="not-liked">🤍</motion.span>
)}
</AnimatePresence>
</motion.button>
);
}
패턴 9: 로딩 → 성공 → 완료 전환
type ButtonState = 'idle' | 'loading' | 'success' | 'error';
function SubmitButton({ state }: { state: ButtonState }) {
return (
<motion.button
animate={{
backgroundColor: state === 'success' ? '#10B981'
: state === 'error' ? '#EF4444'
: '#3B82F6'
}}
transition={{ duration: 0.3 }}
>
<AnimatePresence mode="wait">
{state === 'idle' && <span key="idle">제출하기</span>}
{state === 'loading' && <Spinner key="loading" />}
{state === 'success' && (
<motion.span
key="success"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
>
✓ 완료
</motion.span>
)}
{state === 'error' && <span key="error">✕ 실패. 재시도</span>}
</AnimatePresence>
</motion.button>
);
}
패턴 10: 입력 필드 포커스
.input {
border: 2px solid var(--color-border-default);
transition: border-color 150ms, box-shadow 150ms;
}
.input:focus {
border-color: var(--color-primary-default);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
outline: none;
}
패턴 11: 알림 배지 펄스
@keyframes pulse-badge {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
.notification-badge {
animation: pulse-badge 2s ease-in-out infinite;
}
패턴 12: 드래그 앤 드롭 시각 피드백
function DraggableCard({ children }: Props) {
return (
<motion.div
drag
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
whileDrag={{
scale: 1.05,
boxShadow: '0 20px 40px rgba(0,0,0,0.2)',
cursor: 'grabbing',
}}
dragElastic={0.1}
>
{children}
</motion.div>
);
}
패턴 13: 스켈레톤 shimmer
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
패턴 14: 리스트 항목 삭제
<AnimatePresence>
{items.map(item => (
<motion.li
key={item.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.2 }}
>
{item.name}
</motion.li>
))}
</AnimatePresence>
패턴 15: 페이지 전환 (Next.js)
// app/layout.tsx
import { AnimatePresence } from 'framer-motion';
export default function Layout({ children }) {
return (
<AnimatePresence mode="wait">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
마무리: 좋은 마이크로인터랙션의 3조건
- 목적이 있어야 한다: 사용자 행동에 의미 있는 피드백 제공
- 빠르고 방해가 없어야 한다: 200ms 내외, 과하지 않게
- 일관성이 있어야 한다: 동일한 액션은 동일한 피드백
마이크로인터랙션은 보이지 않을 때 가장 잘 설계된 것이다. 느껴지되 의식하지 못해야 한다.