본문으로 건너뛰기

모션 원칙

모션은 요소가 어디서 왔는지, 어떤 조작이 무엇을 바꿨는지 보여 줄 때만 씁니다. 장식용 모션은 쓰지 않습니다.

  1. 상태에 따라 속성 하나가 바뀌는 경우(색 · hover · 너비)는 CSS transition 으로, 등장 · 퇴장 · 순서처럼 마운트 시점을 알아야 하는 경우는 JS 로 처리합니다.
  2. 이동 거리는 4~16px 를 넘지 않습니다. 더 멀리 움직이면 요소가 나타나는 것이 아니라 날아오는 것처럼 보이고, 목록에서는 옆 행까지 밀리는 것처럼 보입니다.
  3. 퇴장은 등장보다 짧게 합니다. 사라지는 요소 때문에 사용자를 기다리게 하지 않습니다.

시간 토큰을 세 단계로 제한한 이유는 값이 많으면 화면마다 다른 값을 고르게 되어 일관성이 떨어지기 때문입니다. --uyoo-duration-slow 를 320ms 로 정한 것은 움직임이 “이동”으로 인식되는 대략적인 하한이기 때문입니다. 이보다 길면 기다림으로 느껴집니다.

prefers-reduced-motion 에서는 css/base.css 가 움직임만 끕니다. 이동 · 크기 · 회전 · 높이 변화는 즉시 최종 상태로 바뀌고, 색 · 배경 · 그림자 · 투명도 변화는 120ms 로 유지됩니다. 확정 직후의 변경 하이라이트처럼 색만 쓰는 모션은 그대로 재생됩니다. Radix 오버레이의 zoom · slide 도 페이드만 남깁니다. 이 처리는 base.css 에서 일괄로 하므로 컴포넌트마다 따로 대응하지 않아도 됩니다.

시간

기본은 fast · base · slow 세 단계입니다. 같은 값을 JS 에서는 초, CSS 에서는 ms 로 적습니다.

토큰값용도
--uyoo-duration-press90ms버튼 · 행의 누름(손을 뗄 때는 fast). JS 값은 0.09
--uyoo-duration-fast150ms색 · hover · 작은 상태 변화. JS 값은 0.15
--uyoo-duration-base220ms등장 · 퇴장. JS 값은 0.22
--uyoo-duration-slow320ms너비 · 위치처럼 큰 변화. JS 값은 0.32
--uyoo-duration-reduced120ms동작 줄이기에서 모든 시간이 이 값이 됩니다. 남는 것은 색 · 투명도의 페이드뿐입니다. JS 값은 0.12

이징

이름에 uyoo 를 붙인 것은 Tailwind v4 가 --ease-in · --ease-out 을 자체 테마 변수로 쓰기 때문입니다.

토큰값용도
--uyoo-ease-outcubic-bezier(0.22, 1, 0.36, 1)등장의 기본값. 빠르게 시작해 천천히 멈춥니다
--uyoo-ease-incubic-bezier(0.55, 0, 1, 0.45)퇴장. 천천히 시작해 빠르게 사라집니다
--uyoo-ease-glidecubic-bezier(0.2, 0, 0, 1)슬라이드 · 누름. 탭 인디케이터 · 주변 행의 재배치 · 키캡이 가라앉을 때
--uyoo-ease-in-outcubic-bezier(0.65, 0, 0.35, 1)위치 · 크기 이동. 시작과 끝이 느려야 옮겨진 것으로 보입니다(JS ease.inOut)
--uyoo-ease-springcubic-bezier(0.34, 1.56, 0.64, 1)작은 요소의 등장. 목표를 살짝 넘었다가 돌아옵니다. 큰 면에 쓰면 흔들려 보입니다(JS ease.spring)
--uyoo-ease-depthcubic-bezier(0.32, 0.72, 0, 1)결과 반영. 행이 다른 묶음으로 옮겨 가거나 숫자가 굴러갈 때(JS ease.depth)

지속 시간 · 이징 비교

같은 거리를 서로 다른 지속 시간과 이징으로 이동합니다. 버튼을 눌러 나란히 비교해 보세요.

지속 시간 비교. 이징은 --uyoo-ease-out 으로 고정했습니다.

--uyoo-duration-press90ms
--uyoo-duration-fast150ms
--uyoo-duration-base220ms
--uyoo-duration-slow320ms
--uyoo-duration-reduced120ms

이징 비교. 지속 시간은 --uyoo-duration-slow 로 고정했습니다.

--uyoo-ease-out등장의 기본값
--uyoo-ease-in퇴장
--uyoo-ease-glide슬라이드 · 누름
--uyoo-ease-in-out위치 · 크기 이동
--uyoo-ease-spring작은 요소의 등장
--uyoo-ease-depth결과 반영

변경 하이라이트

결과 메시지는 원래 위치에 표시됩니다. 사용자의 시선이 다른 곳에 있을 때 바뀐 위치를 잠시 강조하는 유틸리티입니다.

아직 누르지 않았습니다.

사용법

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 간격

남은 작업 46완료 보고 1,117 / 1,163

펼치기

접힌 목록을 펼치면 높이가 연속적으로 늘어나고 내용이 페이드 인합니다.

height auto · transition.glide(220ms) · 내용 fade 150ms

Variants

각 variant 는 hidden → visible → exit 세 상태를 가집니다. 애니메이션 라이브러리에 바로 넘겨 씁니다.

이름동작사용 시점
fade위치 변화 없이 페이드 인합니다컨테이너 내용을 교체할 때. 이동이 없어 스크롤 위치가 흔들리지 않습니다
rise아래에서 4px 올라오며 나타납니다새로 추가되는 행 · 카드
pop0.92 배에서 원래 크기로 커집니다칩 · 배지처럼 작고 갑자기 나타나는 요소
drawerRight오른쪽에서 12px 슬라이드해 들어옵니다측면 패널
sheetUp아래에서 16px 올라옵니다작은 화면의 바텀 시트
stagger자식 요소를 40ms 간격으로 차례로 등장시킵니다항목 5개 이하의 목록. 자식에는 rise 를 씁니다