모션 원칙
모션은 요소가 어디서 왔는지, 어떤 조작이 무엇을 바꿨는지 보여 줄 때만 씁니다. 장식용 모션은 쓰지 않습니다.
- 상태에 따라 속성 하나가 바뀌는 경우(색 · hover · 너비)는 CSS transition 으로, 등장 · 퇴장 · 순서처럼 마운트 시점을 알아야 하는 경우는 JS 로 처리합니다.
- 이동 거리는 4~16px 를 넘지 않습니다. 더 멀리 움직이면 요소가 나타나는 것이 아니라 날아오는 것처럼 보이고, 목록에서는 옆 행까지 밀리는 것처럼 보입니다.
- 퇴장은 등장보다 짧게 합니다. 사라지는 요소 때문에 사용자를 기다리게 하지 않습니다.
시간 토큰을 세 단계로 제한한 이유는 값이 많으면 화면마다 다른 값을 고르게 되어 일관성이 떨어지기 때문입니다. --uyoo-duration-slow 를 320ms 로 정한 것은 움직임이 “이동”으로 인식되는 대략적인 하한이기 때문입니다. 이보다 길면 기다림으로 느껴집니다.
prefers-reduced-motion 에서는 css/base.css 가 움직임만 끕니다. 이동 · 크기 · 회전 · 높이 변화는 즉시 최종 상태로 바뀌고, 색 · 배경 · 그림자 · 투명도 변화는 120ms 로 유지됩니다. 확정 직후의 변경 하이라이트처럼 색만 쓰는 모션은 그대로 재생됩니다. Radix 오버레이의 zoom · slide 도 페이드만 남깁니다. 이 처리는 base.css 에서 일괄로 하므로 컴포넌트마다 따로 대응하지 않아도 됩니다.
시간
기본은 fast · base · slow 세 단계입니다. 같은 값을 JS 에서는 초, CSS 에서는 ms 로 적습니다.
| 토큰 | 값 | 용도 |
|---|---|---|
| --uyoo-duration-press | 90ms | 버튼 · 행의 누름(손을 뗄 때는 fast). JS 값은 0.09 |
| --uyoo-duration-fast | 150ms | 색 · hover · 작은 상태 변화. JS 값은 0.15 |
| --uyoo-duration-base | 220ms | 등장 · 퇴장. JS 값은 0.22 |
| --uyoo-duration-slow | 320ms | 너비 · 위치처럼 큰 변화. JS 값은 0.32 |
| --uyoo-duration-reduced | 120ms | 동작 줄이기에서 모든 시간이 이 값이 됩니다. 남는 것은 색 · 투명도의 페이드뿐입니다. JS 값은 0.12 |
이징
이름에 uyoo 를 붙인 것은 Tailwind v4 가 --ease-in · --ease-out 을 자체 테마 변수로 쓰기 때문입니다.
| 토큰 | 값 | 용도 |
|---|---|---|
| --uyoo-ease-out | cubic-bezier(0.22, 1, 0.36, 1) | 등장의 기본값. 빠르게 시작해 천천히 멈춥니다 |
| --uyoo-ease-in | cubic-bezier(0.55, 0, 1, 0.45) | 퇴장. 천천히 시작해 빠르게 사라집니다 |
| --uyoo-ease-glide | cubic-bezier(0.2, 0, 0, 1) | 슬라이드 · 누름. 탭 인디케이터 · 주변 행의 재배치 · 키캡이 가라앉을 때 |
| --uyoo-ease-in-out | cubic-bezier(0.65, 0, 0.35, 1) | 위치 · 크기 이동. 시작과 끝이 느려야 옮겨진 것으로 보입니다(JS ease.inOut) |
| --uyoo-ease-spring | cubic-bezier(0.34, 1.56, 0.64, 1) | 작은 요소의 등장. 목표를 살짝 넘었다가 돌아옵니다. 큰 면에 쓰면 흔들려 보입니다(JS ease.spring) |
| --uyoo-ease-depth | cubic-bezier(0.32, 0.72, 0, 1) | 결과 반영. 행이 다른 묶음으로 옮겨 가거나 숫자가 굴러갈 때(JS ease.depth) |
지속 시간 · 이징 비교
같은 거리를 서로 다른 지속 시간과 이징으로 이동합니다. 버튼을 눌러 나란히 비교해 보세요.
지속 시간 비교. 이징은 --uyoo-ease-out 으로 고정했습니다.
이징 비교. 지속 시간은 --uyoo-duration-slow 로 고정했습니다.
변경 하이라이트
결과 메시지는 원래 위치에 표시됩니다. 사용자의 시선이 다른 곳에 있을 때 바뀐 위치를 잠시 강조하는 유틸리티입니다.
아직 누르지 않았습니다.
사용법
CSS 와 JS 가 같은 값을 공유합니다.
CSS
/* 속성을 지정하지 않은 transition 에는 시스템 기본 이징이 적용된다(150ms · ease-uyoo-out). */
<button className="transition hover:bg-(--uyoo-hover)">호버</button>
/* 시간을 바꿀 때. duration 은 테마 네임스페이스가 없어 임의 값(arbitrary value) 문법을 쓴다. */
<div className="transition-[width] duration-(--uyoo-duration-slow) ease-uyoo-glide" />
/* 키프레임에서 쓸 때. */
.rise { animation: rise var(--uyoo-duration-base) var(--uyoo-ease-out) both; }JS
import { duration, ease, transition, variants } from "@uyoolabs/ui/motion";
// 라이브러리 의존성이 없는 상수다. 애니메이션 라이브러리에 바로 넘긴다.
<m.div variants={variants.rise} initial="hidden" animate="visible" exit="exit" />
<m.aside animate={{ x: 0 }} transition={transition.move} />
// 직접 쓸 때. 시간은 초 단위, 베지어는 배열이다.
el.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: duration.base * 1000,
easing: `cubic-bezier(${ease.out.join(",")})`,
});상황별 모션
2026-10-19 모션 시안에서 상황별로 정한 값입니다. 사용자가 반복하는 조작은 짧고 눈에 띄지 않게, 작업 결과가 반영되는 순간만 조금 더 눈에 띄게 합니다.
| 상황 | 모션 | 값 |
|---|---|---|
| hover · press | 키캡은 손을 올리면 1px 솟고(두께 3), 누르면 두께만큼 대각으로 가라앉습니다(두께 0). 평평한 행 · 메뉴 항목은 hover · press 틴트만 바뀝니다 | --uyoo-shadow-key(-hover · -press) · ease-glide · press 90ms / release 150ms |
| 탭 인디케이터 | 선택한 탭으로 슬라이드합니다 | layoutId · transition.glide(220ms) |
| 목록 이동 | 완료 목록으로 옮겨 가는 행이 리프트된 상태로 새 위치에 이동합니다. 새 행은 4px 올라오며 나타나고, 빠지는 행은 페이드 아웃됩니다 | layoutId + variants.carry · transition.carry(320ms) |
| 시트 · 메뉴 | 시트는 12px, 메뉴는 4px 이동합니다. 뒤 화면은 움직이지 않습니다 | variants.drawerRight · rise |
| 인라인 결과 | 체크 아이콘이 팝 인하고 텍스트가 롤링되며 바뀝니다. 너비는 더 긴 텍스트 기준으로 고정합니다 | variants.confirm · variants.swap |
| 숫자 | 바뀐 자릿수만 한 칸 롤링됩니다(오른쪽 자리부터 40ms 간격) | variants.digit · transition.carry |
| 펼치기 | 높이가 연속적으로 늘어나고 내용이 페이드 인합니다 | height auto · transition.glide |
상황별 모션 데모
장면마다 버튼을 눌러 직접 확인합니다. 값은 아래 상황별 모션 표와 같습니다.
OS 에서 동작 줄이기를 켠 사용자에게는 앱에서도 이동 · 크기 변화 없이 색과 투명도만 120ms 로 바뀝니다. 이 페이지의 데모는 비교를 위해 그 설정과 관계없이 움직입니다.
hover · press
버튼과 행을 직접 눌러 봅니다. hover 하면 리프트 없이 배경색만 바뀌고, 누르는 동안 살짝 축소됩니다.
classes.ts · press 0.97(행 0.99) · 90ms / release 150ms
탭 인디케이터
탭을 누르면 밑줄이 선택한 탭으로 슬라이드합니다. 한 화면 안에서 내용만 바뀌는 탭에 씁니다. 페이지 주소가 바뀌는 TabNav 는 페이지가 다시 그려지므로 슬라이드하지 않습니다.
layoutId · transition.glide(220ms)
목록 이동
‘한도 초과 안내 문구’가 완료로 옮겨 가고, 새 작업이 들어오고, 가장 오래된 완료가 빠집니다. 한 번 더 누르면 되돌아갑니다.
layoutId + variants.carry · transition.carry(320ms) · 새 행 variants.rise · 빠지는 행 variants.fade
진행 중 3
완료 3
시트 · 메뉴
시트는 오른쪽에서 12px 이동하며 나타나고, 뒤 화면은 움직이지 않습니다. 메뉴는 4px 이동하며 페이드 인하고 확대하지 않습니다.
시트 variants.drawerRight(12px · 220ms) · 메뉴 slide-in 4px · 150ms
인라인 결과
결과는 토스트 없이 누른 자리에서 바뀝니다. 체크 아이콘이 팝 인하고 텍스트가 롤링되며 바뀌고, 버튼 너비는 더 긴 텍스트 기준으로 고정됩니다.
variants.confirm(체크 팝 인) · variants.swap(텍스트 롤링) · 너비 고정
숫자
작업 하나가 끝나 남은 작업이 줄고 완료 보고가 늘어납니다. 바뀐 자릿수만 한 칸 롤링됩니다. 세 번째에 자리올림(1,119 → 1,120)을 볼 수 있습니다.
variants.digit · transition.carry · 오른쪽 자리부터 40ms 간격
펼치기
접힌 목록을 펼치면 높이가 연속적으로 늘어나고 내용이 페이드 인합니다.
height auto · transition.glide(220ms) · 내용 fade 150ms
Variants
각 variant 는 hidden → visible → exit 세 상태를 가집니다. 애니메이션 라이브러리에 바로 넘겨 씁니다.
| 이름 | 동작 | 사용 시점 |
|---|---|---|
| fade | 위치 변화 없이 페이드 인합니다 | 컨테이너 내용을 교체할 때. 이동이 없어 스크롤 위치가 흔들리지 않습니다 |
| rise | 아래에서 4px 올라오며 나타납니다 | 새로 추가되는 행 · 카드 |
| pop | 0.92 배에서 원래 크기로 커집니다 | 칩 · 배지처럼 작고 갑자기 나타나는 요소 |
| drawerRight | 오른쪽에서 12px 슬라이드해 들어옵니다 | 측면 패널 |
| sheetUp | 아래에서 16px 올라옵니다 | 작은 화면의 바텀 시트 |
| stagger | 자식 요소를 40ms 간격으로 차례로 등장시킵니다 | 항목 5개 이하의 목록. 자식에는 rise 를 씁니다 |