Apple UI가 기분 좋은 이유
macOS Dock에서 아이콘 위로 마우스를 올리면 아이콘이 부드럽게 커진다. iPhone에서 버튼을 누르면 살짝 눌리는 느낌이 있다. App Store 카드를 기울이면 빛 반사가 따라온다. 개인적으론 이런 마이크로 인터랙션이 기분 좋은 느낌의 이유라고 생각한다.
오늘 글에서 주의 깊게 볼 내용은 spring physics(스프링 물리) 와 inertia(관성) 이다. 특히 Framer-motion 라이브러리를 많이 참고했는데, CSS transition의 ease-in-out과는 조금 다른 접근일 수도 있겠다.
Spring Physics
CSS transition은 시간 기반이다. "0.3초 동안 A에서 B로" 같은 식으로, 지속 시간과 이징 커브를 지정한다.
/* 시간 기반 */
transition: transform 0.3s ease-in-out;Spring (framer-motion의 spring을 생각해보자)은 물리 기반이다. 지속 시간 대신 스프링의 물리 속성을 정의하면, 애니메이션이 자연스럽게 수렴할 때까지 진행된다.
// 물리 기반 - 탄성 있고 자연스러움
useSpring(value, {
stiffness: 300, // k - 복원 강도(상대값)
damping: 30, // c - 감쇠 강도(상대값)
mass: 1, // m - 관성/무게감(상대값)
})운동 방정식
framer-motion의 spring은 역학의 감쇠 조화 진동자(Damped Harmonic Oscillator) 를 풀고 있다.
질량 에 매달린 스프링(강성 )이 점성 유체(감쇠 ) 안에서 진동하는 모델이다.
이 세 파라미터가 곧 framer-motion의 mass, stiffness, damping이다.
| 파라미터 | 역할 | 효과 | 비유 |
|---|---|---|---|
| stiffness () | 복원 강도(상대값) | 값이 클수록 빠르고 팽팽하게 복귀 | 얼마나 세게 당기는지 |
| damping () | 감쇠 강도(상대값) | 값이 클수록 진동이 빨리 잦아듦 | 공기 저항, 마찰 |
| mass () | 관성/무게감(상대값) | 값이 클수록 느리고 묵직하게 반응 | 물체의 무게감 |
수학 구조는 실제 물리(감쇠 조화 진동자)와 같지만, stiffness·damping·mass의 값은 SI 단위(N/m, kg 등)로 캘리브레이션된 것이 아니라, UI 애니메이션에서 흔히 쓰이는 단위 없는 튜닝 파라미터로 보는 것이 더 정확하다. 수치 자체보다 세 값의 비율이 움직임의 성격을 결정한다.
해석적 풀이 - 세 가지 감쇠 영역
이 ODE의 해는 감쇠비(damping ratio) 에 따라 세 가지로 나뉜다.
는 감쇠비, 는 고유 진동수(undamped angular frequency)다. 쉽게 말하면, 는 "얼마나 출렁이느냐"를 결정하는 하나의 숫자다. stiffness·damping·mass 세 값을 넣으면 가 나오고, 이 값이 1보다 작으면 출렁이고, 1이면 딱 맞게 멈추고, 1보다 크면 느릿느릿 접근한다.
framer-motion은 수치 적분(Euler, RK4 등)이 아니라 해석적(analytical) 풀이를 쓴다. 매 프레임 ODE를 반복 적분하는 것이 아니라, 시각 를 넣으면 위치가 바로 나오는 닫힌 형태의 해를 사용하는 것 같다.
Underdamped () - 진동하며 수렴:
대부분의 UI spring이 이 영역이다. 가 진폭을 지수적으로 감소시키고, /이 진동을 만든다. 결과적으로 목표를 지나쳤다가 돌아오고, 점점 작게 흔들리며 수렴 하는 움직임이 된다.
Critically damped () - 진동 없이 가장 빠르게 수렴:
Overdamped () - 느리게 목표로 접근:
수식이 복잡해 보이지만 핵심은 단순하다 - 세 영역 모두 목표를 향해 이동하면서 점점 멈춘다는 것은 같고, 차이는 멈추는 방식뿐이다.
- Underdamped: 목표를 지나쳤다가 왔다 갔다 하며 수렴 (출렁이는 젤리)
- Critically damped: 딱 한 번에 목표에 도달, 가장 빠름 (고급 도어 클로저)
- Overdamped: 진동 없이 느리게 접근 (꿀에 빠진 공)
값 하나로 이 세 영역이 결정되니, spring 튜닝은 결국 를 조절하는 것과 같다.
왜 해석적 풀이가 중요할까? 수치 적분(Euler, RK4)은 매 프레임마다 이전 상태에서 다음 상태를 계산한다. 프레임이 밀리면 dt가 커지고, 누적 오차로 시뮬레이션이 불안정해질 수 있다. 해석적 풀이는 만 넣으면 정확한 위치가 나오므로, dt 누적 오차로 인한 수치적 불안정성이 적다. 탭 전환이나 프레임 드롭이 발생해도 상태가 망가지기보다는 '올바른 시간 위치'를 샘플링하기 때문에, framer-motion이 탭 전환 후에도 애니메이션이 크게 깨지지 않는다. (다만 프레임이 크게 건너뛰면 시각적으로 점프가 보일 수는 있다.)
감쇠비로 읽는 spring config
Magnetic Button과 Tilt Card의 config를 로 환산해보면:
가 1에 가까울수록 진동 없이 매끄럽고, 0에 가까울수록 많이 흔들린다. framer-motion의 useSpring은 이 물리 시뮬레이션을 60fps로 실행한다. 값이 바뀌면 목표를 향해 튕기듯 이동하고, 감쇠에 의해 점점 진폭이 줄어들며 수렴한다. 목표가 중간에 바뀌어도 현재 속도를 유지한 채 자연스럽게 방향을 전환한다 - 이런 속도 보존(velocity continuity)은 CSS transition 단독으로는 구현하기 어려운 부분이다.
Magnetic Button
커서가 버튼 반경(150px) 안에 들어오면 버튼이 커서 쪽으로 끌려오고, 밖으로 나가면 스프링으로 원래 자리에 복귀한다.
* 반경 150px 안에서 커서를 움직여 보세요. 모바일에서는 탭하세요.
구현 시뮬레이션
// 1. 커서와 버튼 중심 사이의 유클리드 거리
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
// 2. 반경 안이면 거리에 비례해 끌어당김
if (distance < RADIUS) {
const strength = 1 - distance / RADIUS; // 가까울수록 강함 (0→1)
rawX.set(deltaX * strength * 0.4);
rawY.set(deltaY * strength * 0.4);
}
// 3. useSpring이 감쇠 조화 진동자로 보간
const x = useSpring(rawX, { stiffness: 300, damping: 30 });핵심은 반경 기반 attraction에 있다. 일반적인 hover 효과는 요소 위에 커서가 올라가야 동작하지만, 마그네틱 버튼은 요소 근처에만 와도 반응한다. 은 거리에 따라 선형 감쇠하는 힘의 장(force field)이다. 중심에서 힘이 최대(1), 경계에서 0으로 떨어진다.
여기에 0.4를 곱하는 것은 최대 변위를 제한하는 역할이다. 커서가 정확히 중심에 있으면 , 즉 커서-중심 거리의 40%만큼만 따라간다. 100% 따라가면 커서에 달라붙는 느낌이라 마그네틱 끌림의 느낌이 사라진다.
spring config { stiffness: 300, damping: 30 } → . underdamped지만 1에 가까워서 overshoot이 거의 없는 snappy 세팅이다. 커서가 반경 밖으로 나가면 rawX가 0으로 돌아가고, spring이 원위치로 탄성 복귀한다.
macOS Dock Magnification
macOS Dock의 시그니처 효과. 커서에 가까운 아이콘일수록 크게 확대되고, 멀어질수록 원래 크기로 돌아간다. macOS에서의 동작 기작을 아래와 같이 Mocking 해볼 수 있는데,
* 아이콘 위로 커서를 이동해 보세요
구현 시뮬레이션
// 공유 mouseX 값 → 각 아이콘이 자기 위치와의 거리 계산
const distance = useTransform(mouseX, (val) => {
const rect = el.getBoundingClientRect();
const center = rect.left + rect.width / 2;
return Math.abs(val - center);
});
// 5-point 거리→크기 매핑 (Gaussian-like 스케일링)
const size = useSpring(
useTransform(distance,
[0, 60, 120, 180, 240], // 거리 (px)
[72, 64, 56, 48, 48] // 크기 (px)
),
{ stiffness: 300, damping: 30 }
);비결은 5-point input range다. Dock의 확대 곡선은 가우시안에 가까운 종(bell)형 커브로 느껴지며, useTransform의 구간별 선형 보간으로 충분히 비슷하게 근사할 수 있다. 중앙(0px)에서 급격히 크고, 양옆(120px+)에서 완만하게 base로 수렴하는 곡선이 된다.
mouseX를 useMotionValue로 공유하고 각 아이콘이 useTransform으로 자기 거리를 독립적으로 계산하는 구조도 중요하다. useMotionValue → useTransform → useSpring 파이프라인은 React state를 거치지 않는다. 값 변경이 React 리렌더링을 트리거하지 않고 모션 값끼리 직접 전파되므로, 7개 아이콘이 동시에 반응해도 60fps를 유지할 수 있다.
Elastic Tilt Card
커서 위치에 따라 카드가 3D로 기울어지고, 빛 반사 오버레이가 커서를 따라 이동한다. App Store의 앱 카드에서 볼 수 있는 효과다.
FEATURED
3D Tilt Card
Move your cursor to tilt
* 카드 위에서 커서를 움직여 3D 틸트 효과를 확인하세요
구현 시뮬레이션
// 커서 위치를 0~1로 정규화
const x = (e.clientX - rect.left) / rect.width; // 0(좌) ~ 1(우)
const y = (e.clientY - rect.top) / rect.height; // 0(상) ~ 1(하)
// 0.5를 빼서 -0.5~0.5 → 30을 곱해 ±15° 범위로 매핑
rawRotateX.set((y - 0.5) * -30); // 위쪽 커서 → 양수 회전 (앞으로 기울임)
rawRotateY.set((x - 0.5) * 30); // 오른쪽 커서 → 양수 회전
// 빛 반사 오버레이 - 커서 위치에 radial-gradient 중심 매핑
const glareBackground = useTransform(
[glareX, glareY],
([gx, gy]) =>
`radial-gradient(circle at ${gx}% ${gy}%,
rgba(255,255,255,0.25) 0%, transparent 60%)`
);rotateX에 Y 좌표, rotateY에 X 좌표를 넣는 것이 직관에 반하지만, CSS 3D transform에서 rotateX는 X축을 중심으로 회전(= 상하 기울임), rotateY는 Y축 중심 회전(= 좌우 기울임)이기 때문이다. Y에 음수를 곱하는 이유는 커서가 위에 있으면 카드 윗부분이 뒤로 기울어야 자연스럽기 때문이다.
여기서는 { stiffness: 100, damping: 20 } → , critically damped다. 진동 없이 부드럽게 따라오는 움직임이 카드의 무게감을 표현한다. snappy spring()을 쓰면 너무 가볍고 반응적인 느낌이라, 손에 들고 있는 실물 카드의 느낌이 사라진다.
perspective: 800px은 카메라와 평면 사이의 가상 거리다. CSS에서 perspective는 3D transform의 소실점 깊이를 결정한다. 작을수록 원근감이 과장되고(어안 렌즈), 클수록 평평해진다(망원 렌즈). 카드 크기(약 288px) 대비 2.5~3배인 800px이 자연스러운 밸런스다.
빛 반사 오버레이는 별도의 <div>에 radial-gradient로 구현한다. pointer-events-none으로 마우스 이벤트를 통과시키는 것이 중요하다.
Inertia Drag - 지수 감쇠
Spring이 목표 위치로 돌아가려는 힘 (복원력)이라면, Inertia는 현재 속도를 유지하려는 힘 (관성)이다. 뉴턴의 제1법칙, 관성의 법칙 그 자체다. iOS의 스크롤을 손가락으로 튕기면 속도에 비례해 미끄러지다 멈추는 것이 대표적인 inertia 애니메이션이다.
원을 빠르게 드래그한 뒤 놓아보자. 놓는 순간의 속도가 클수록 더 멀리 미끄러지고, 벽에 부딪히면 탄성 있게 튕긴다.
* 원을 드래그한 뒤 놓아보세요. 속도에 비례해 미끄러집니다.
구현 시뮬레이션
<motion.div
drag
dragConstraints={containerRef} // 경계 박스
dragElastic={0.15} // 경계 밖으로 살짝 늘어남
dragTransition={{
power: 0.3, // 관성 계수 - 높을수록 멀리 감
timeConstant: 200, // 감속 시간 상수 (ms)
bounceStiffness: 400, // 벽 충돌 시 스프링 강성
bounceDamping: 25, // 벽 충돌 시 감쇠
}}
/>놓는 순간의 속도 - 어떻게 측정하는가?
드래그 중 framer-motion은 내부적으로 포인터 위치와 타임스탬프를 히스토리 배열에 기록한다. 놓는 순간, 마지막 100ms 구간의 평균 속도를 계산한다.
// framer-motion 내부 (PanSession)
// 히스토리에서 ~100ms 전 포인트를 찾아 속도 계산
const time = (lastPoint.timestamp - prevPoint.timestamp) / 1000;
const velocity = {
x: (lastPoint.x - prevPoint.x) / time, // px/s
y: (lastPoint.y - prevPoint.y) / time,
};100ms 윈도우를 쓰는 이유가 있다. 마지막 한 프레임(16ms)만 보면 노이즈에 취약하고, 너무 긴 구간을 보면 "빠르게 방향을 바꾼 뒤 놓기" 같은 제스처에서 이전 방향이 섞인다. 100ms는 사람의 의도적 동작을 포착하기에 적절한 윈도우다.
지수 감쇠 모델
놓은 후의 움직임은 지수 감쇠(exponential decay) 로 모델링된다. 속도에 비례하는 마찰력 가 작용하는 환경에서의 운동 방정식의 해와 같다.
여기서 (tau)가 timeConstant다. timeConstant가 커질수록 감쇠가 느려져 더 오래 미끄러지고, 작을수록 빠르게 멈춘다. 대략 만큼의 시간이 지나면 남은 이동량이 눈에 띄게 줄어드는 지수 감쇠 곡선을 따른다.
timeConstant: 200이면 놓은 뒤 약 200ms()가 지날 때 남은 거리가 약 37%(≈ 1/3)로 줄어든다. 400ms(2)면 약 14%(≈ 1/7), 600ms(3)면 약 5%(≈ 1/20)까지 줄어 거의 멈춘 것처럼 보인다. 값을 높이면 얼음 위에서 미끄러지는 느낌, 낮추면 카펫 위에서 멈추는 느낌이라고 생각하면 된다.
| 파라미터 | 수식에서의 역할 | 효과 |
|---|---|---|
| power | 초기 속도 배율. 0이면 즉시 정지, 1이면 100% 관성 | |
| timeConstant () | 의 시간 상수 | 높을수록 천천히 감속 (= 더 미끄러움) |
Spring vs Inertia의 본질적 차이 - 두 모델의 미분방정식을 비교하면 명확하다.
- Spring: - 위치에 비례하는 복원력()이 있다. 평형점을 안다.
- Inertia: - 복원력이 없다. 초기 속도로 출발해 마찰만으로 감속한다.
Spring은 어디로 가야 할지 아는 애니메이션이고, Inertia는 얼마나 세게 밀었는지만 아는 애니메이션이다. 드래그처럼 사용자가 방향을 정하는 인터랙션에는 inertia가, 시스템이 위치를 정하는 인터랙션에는 spring이 적합하다.
경계 충돌 - Inertia에서 Spring으로의 전환
경계(dragConstraints)에 도달하면 framer-motion은 inertia를 중단하고 spring으로 전환한다. 이때 inertia의 현재 속도가 spring의 초기 속도()로 전달된다.
// framer-motion 내부 (inertia generator)
if (isOutOfBounds(value)) {
// 현재 inertia 속도를 spring 초기 속도로 전달
springAnim = spring({
keyframes: [currentValue, nearestBoundary],
velocity: currentInertiaVelocity, // 속도 보존!
stiffness: bounceStiffness, // 400
damping: bounceDamping, // 25
});
}이 속도 전달(velocity handoff) 이 자연스러운 바운스의 핵심 중 하나다. 빠르게 벽에 부딪히면 강하게 튕기고, 느리게 닿으면 살짝 밀렸다 돌아온다. 단순히 벽에서 반사가 아니라, spring의 감쇠 진동자가 경계를 목표로 삼아 수렴한다.
dragElastic: 0.15는 경계를 넘어서 약간 늘어났다가 돌아오는 러버밴딩 효과를 만든다. iOS 스크롤에서 끝에 도달했을 때 살짝 늘어나는 느낌이 이것이다. 0이면 딱딱하게 멈추고, 1이면 경계를 무시하고 자유롭게 늘어난다.
접근성
마이크로 인터랙션은 재밌지만, 모든 사용자에게 적합하지는 않다. 과도한 모션은 어지러움을 유발할 수 있다.
const reduced = useReducedMotion();
// reduced가 true이면 모든 모션 비활성화
React.useEffect(() => {
if (reduced) return;
// ... 이벤트 리스너 등록
}, [reduced]);위 데모들은 모두 useReducedMotion()을 체크한다. OS 설정에서 동작 줄이기를 켜면 prefers-reduced-motion: reduce 미디어 쿼리가 활성화되고, 모든 spring 애니메이션이 비활성화된다. 버튼은 여전히 클릭할 수 있고, 카드 내용도 읽을 수 있다. 인터랙션이 장식이지 기능이 아니기 때문에, 제거해도 사용성에 영향이 없다.
터치 디바이스에서는 mousemove 대신 whileTap으로 최소한의 피드백(scale 축소)을 제공한다.
나가며
네 가지 데모의 물리 모델을 비교하면 패턴이 보인다
| 데모 | 물리 모델 | 핵심 파라미터 | / | 성격 |
|---|---|---|---|---|
| Magnetic Button | Spring | Snappy - 즉각 반응 | ||
| Dock Magnification | Spring | Snappy - 빠른 추적 | ||
| Elastic Tilt Card | Spring | Critical - 무게감 | ||
| Inertia Drag | Decay | Momentum - 미끄러짐 |
Spring과 Inertia는 서로 보완적이다. 실제 Apple UI는 이 둘을 조합한다 - iOS 스크롤은 손가락이 닿아 있을 때는 1:1 추적, 놓으면 inertia()로 미끄러지고, 끝에 도달하면 spring()으로 바운스한다. 속도가 inertia에서 spring으로 매끄럽게 전달되기 때문에 사용자는 하나의 연속된 물리 시뮬레이션으로 느낀다.
CSS transition으로도 mousemove 기반 인터랙션은 가능하다. 하지만 물리 기반 애니메이션이 빛나는 순간은 목표가 연속적으로 바뀌거나 속도가 보존되어야 할 때다. 커서가 움직이면 매 프레임마다 목표 위치가 갱신되는데, CSS transition은 매번 처음부터 다시 시작하기 쉬운 반면, spring은 현재 속도를 유지한 채 자연스럽게 방향을 전환한다. Inertia의 속도 기반 감쇠 역시 CSS transition 단독으로는 자연스럽게 표현하기 어렵다.
레퍼런스
물리학
- Feynman, R. P. The Feynman Lectures on Physics, Vol. I, Ch. 24: Transients - 감쇠 조화 진동자의 해석적 풀이. 이 글의 spring 수식 전체의 기반
- Feynman, R. P. The Feynman Lectures on Physics, Vol. I, Ch. 9: Newton's Laws of Dynamics - 관성의 법칙 (뉴턴 제1법칙)
- HyperPhysics - Damped Oscillation - Georgia State Univ. 물리학과. 감쇠비 와 세 가지 감쇠 영역 시각화
- Wikipedia - Exponential Decay - 지수 감쇠 모델 와 시간 상수 의 정의
- Wolfram MathWorld - Gaussian Function - 가우시안 함수의 수학적 정의. Dock 확대 곡선의 근사 모델 참고용
구현 소스코드 (1차 자료)
- motion - spring generator - framer-motion의 해석적 spring 풀이 구현
- motion - inertia generator - 지수 감쇠 기반 inertia 구현
- motion - PanSession - 드래그 속도 측정 (100ms lookback window)
플랫폼 공식 문서
- Designing Fluid Interfaces - WWDC 2018 - Apple의 spring/inertia 디자인 철학. velocity handoff 개념의 원전
- W3C CSS Transforms Module Level 2 -
perspective- CSS perspective 속성 명세 - MDN -
prefers-reduced-motion- 접근성 미디어 쿼리