GODRICH

리액트 애니메이션 라이브러리 없이 쓰는 CSS 9종

리액트로 만든 화면에 자연스러운 움직임을 넣고 싶을 때 제일 먼저 떠오르는 게 리액트 애니메이션 라이브러리를 까는 일이지만, 사실 컴포넌트가 나타나고 사라지는 타이밍만 잡으면 CSS 만으로 되는 것도 많습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 인기순이 아니라 리액트 컴포넌트가 실제로 겪는 순서대로 놓았습니다. 화면에 처음 나타나고(01) → 닫힐 때 사라지고(02) → 그 사이에서 항목이 늘고 주는 목록이 되고(03) → 상태에 따라 모양이 바뀌고(04) → 같은 등장 동작을 다시 재생해야 하고(05) → 이걸 실제로 빌드하는 설정을 잠깐 짚고(06) → 화면 전체가 다른 화면으로 바뀌고(07) → 아직 안 온 데이터를 기다리고(08) → 마지막으로 페이지 자체가 바뀌는(09) 순서입니다. 아이디어형(리액트에서 흔히 안 쓰는 조합) 세 개는 01·02·07번입니다.

01마운트 시 @starting-style 페이드

카드 컴포넌트가 마운트되는 순간 @starting-style 이 DOM 에 없던 시점의 값을 정의해 줘서, useEffect 로 "마운트됨" 플래그를 따로 만들지 않아도 opacity 가 0에서 1로 트랜지션됩니다. 새로 렌더되는 알림 카드나 리스트에 막 추가된 항목에 잘 맞습니다.

@starting-style@keyframes mf-mountReact 19
@starting-style {
  .mf-card { opacity: 0; transform: translateY(10px) scale(.94); }
}
.mf-card {
  opacity: 1;
  transform: none;
  transition: opacity $duration $easing, transform $duration $easing;
}

02언마운트 exit(allow-discrete)

닫기 버튼을 누르면 바로 언마운트하지 않고 class="is-exiting" 을 먼저 붙여, transition-behavior: allow-discretedisplay 전환을 늦춘 뒤 onTransitionEnd 에서 실제 상태를 지웁니다. 모달을 닫거나 토스트가 사라질 때 이 패턴이 없으면 opacity·transform 트랜지션이 재생될 새도 없이 화면에서 잘려 나갑니다.

allow-discreteonTransitionEndexit class
.ue-dialog {
  display: flex;
  opacity: 1;
  transform: scale(1);
  transition: opacity $duration $easing, transform $duration $easing, display $duration allow-discrete;
}
.ue-dialog.is-exiting {
  display: none;
  opacity: 0;
  transform: scale(.92) translateY(6px);
}

03리스트 추가/삭제 애니메이션

상태 배열에 항목을 추가·삭제하면 새 <li> 는 01번처럼 들어오고, 지워지는 항목은 02번과 같은 exit 클래스 패턴을 한 번 더 써서 애니메이션 라이브러리 없이도 순서가 맞습니다. 할 일 목록이나 장바구니 줄 항목, 알림 스택에 그대로 옮겨 쓸 수 있습니다.

key@keyframes la-enter@keyframes la-leave
{items.map((it) => (
  <li
    key={it.id}
    className={`${styles.item} ${it.leaving ? styles.leaving : styles.entering}`}
    onTransitionEnd={() => it.leaving && finishRemove(it.id)}
  >
    {it.label}
    <button className={styles.remove} onClick={() => startRemove(it.id)}>×</button>
  </li>
))}

04클래스 토글 상태 전환

불리언 상태 하나가 클래스 이름 하나로 그대로 매핑되고, 그 사이의 움직임은 전부 CSS transition 이 맡습니다. "상태는 클래스가 정하고 움직임은 CSS 가 정한다"는 리액트에서 가장 흔한 패턴이라, 펼침·접힘 패널이나 활성 상태 내비게이션에 바로 씁니다.

useStateclassNametransition
.tg-panel:not(.is-open) .tg-panel__chevron { transform: rotate(-90deg); }
.tg-panel:not(.is-open) .tg-panel__body { opacity: 0; transform: scaleY(0); }
.tg-panel__body {
  transform-origin: top;
  transition: opacity $duration $easing, transform $duration $easing;
}

05key 로 애니메이션 재트리거

key prop 값을 바꾸면 리액트가 그 컴포넌트를 통째로 언마운트하고 새 인스턴스로 다시 마운트해서, 마운트 때 한 번만 도는 등장 애니메이션을 원할 때마다 재생시킬 수 있습니다. 정답 확인 흔들림을 다시 보여주거나, 점수가 바뀔 때마다 축하 효과를 다시 터뜨리고 싶을 때 씁니다.

key propremount@keyframes
function Badge({ value }: { value: number; key?: number }) {
  // key 가 바뀔 때마다 리액트가 이 요소를 언마운트·재마운트해,
  // 마운트 1회성인 pop 키프레임이 매번 처음부터 다시 재생된다.
  return <span className={styles.badge}>+{value}</span>;
}
// 호출부: <Badge key={score} value={score} />

06CSS Modules+SCSS 세팅

이 글의 react/ 폴더 9개가 공통으로 기대는 실제 빌드 설정을 보여주는 항목으로, *.module.scss 파일이 styles.foo 같은 로컬 스코프 클래스명으로 컴파일돼 전역 충돌 없이 컴포넌트마다 스타일이 갇힙니다. 컴포넌트 스코프 SCSS 를 쓰고 싶은 모든 리액트 프로젝트에 해당합니다.

*.module.scssstyles.cardscoped classname
// styles.card · styles.title 은 하드코딩한 문자열이 아니라,
// CssModulesSetup.module.scss 의 클래스명이 빌드 때 실제로 컴파일된 값이다.
<div className={styles.term}>
  <div className={styles.accent}>styles.card → "{styles.card}"</div>
  <div className={styles.accent}>styles.title → "{styles.title}"</div>
</div>

07startViewTransition+상태 갱신

document.startViewTransition() 이 리액트 상태 갱신을 감싸면, 브라우저가 바뀌기 전·후 DOM 스냅샷을 자동으로 크로스페이드해줘서 바뀐 요소마다 애니메이션을 손으로 걸지 않아도 됩니다. 단, 콜백 안에서 setState 만 하면 리액트 18의 자동 배칭이 커밋을 늦춰버리기 때문에 flushSync 로 그 갱신을 동기적으로 강제해야 스냅샷 타이밍이 맞습니다.

startViewTransitionflushSync@keyframes vt-cross-in
const toggle = () => {
  const doc = document as DocWithViewTransition;
  const next = mode === "list" ? "grid" : "list";
  if (doc.startViewTransition) {
    doc.startViewTransition(() => flushSync(() => setMode(next)));
  } else {
    setMode(next);
  }
};

08Suspense 스켈레톤 교체

React.Suspense 의 fallback 자리에 CSS 셰머 스켈레톤을 넣어두면, 지연 로드된 컴포넌트가 준비됐을 때 fallback 은 빠지고 실제 콘텐츠가 같은 자리에서 페이드로 들어와 화면이 툭 튀지 않습니다. 코드 스플릿된 라우트 콘텐츠나 지연 로드되는 대시보드 위젯에 잘 맞습니다.

React.lazySuspense fallbackshimmer
.sk-bar {
  background: linear-gradient(90deg, rgba(255,255,255,.1) 25%, rgba(255,255,255,.24) 50%, rgba(255,255,255,.1) 75%);
  background-size: 200% 100%;
  animation: sk-shimmer 1.1s linear infinite;
}
@keyframes sk-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

09라우트 전환 애니메이션

URL 파라미터를 컴포넌트의 key 로 그대로 쓰거나 라우트 전환을 startViewTransition 으로 감싸면, 나가는 페이지와 들어오는 페이지가 순간 전환 대신 CSS 트랜지션으로 이어집니다. key 를 안 쓰면 리액트가 같은 컴포넌트를 재사용(patch)해버려서 언마운트·마운트 사이클 자체가 생기지 않는다는 점이 이 항목의 핵심입니다.

key by routestartViewTransitionexit/enter
const navigate = () => {
  const next = (route + 1) % ROUTES.length;
  if (typeof document !== "undefined" && "startViewTransition" in document) {
    (document as any).startViewTransition(() => setRoute(next));
  } else {
    setRoute(next);
  }
};
// <Page key={ROUTES[route]} label={ROUTES[route]} />

어디서 깨지는가 — React 애니메이션의 흔한 함정

이 아홉 개를 실제로 만들면서 가장 많이 본 실패는 08번(Suspense 스켈레톤)이었습니다. 처음 구현에서는 스켈레톤과 콘텐츠를 둘 다 position: absolute 로 겹쳐두고 바깥 카드에 별도 높이를 안 줬더니, 카드 자체가 찌그러진 40px짜리 띠로 렌더됐습니다 — 절대 위치 자식은 부모의 높이 계산에 들어가지 않는다는, CSS 를 다뤄본 사람은 다 아는 함정인데 리액트 컴포넌트로 감싸놓으니 눈에 잘 안 띄었습니다. 또 하나는 03번(리스트 추가/삭제)에서 320px 폭으로 줄이니 텍스트가 두 줄로 줄바꿈되면서 목록 전체 높이가 뷰포트를 넘어 버리는 문제로, 압축 파일 안 아홉 개를 열 때 쓰는 비밀번호 ndwa6jaf 를 문장 그대로 붙여넣기 전에 항목마다 white-space: nowraptext-overflow: ellipsis 로 한 줄을 강제해야 같은 문제를 겪지 않습니다. key 를 다루는 05·09번도 흔한 함정이 있는데, key 값을 안 바꾸고 내용물만 바꾸면 같은 인스턴스가 그대로 유지돼 마운트 애니메이션이 아예 재생되지 않습니다.

접근성 — 아홉 개가 reduced-motion 에서 남기는 것

아홉 데모 전부 prefers-reduced-motion: reduce 블록에서 무한 루프 애니메이션을 끄고, 최종 상태(마운트면 보임, 접힘이면 접힘, 라우트면 도착 화면)로 바로 고정합니다. 01·02번처럼 트랜지션이 정보 전달의 전부가 아닌 항목은 애니메이션이 없어도 기능이 그대로 남고, 05번(key 재트리거)처럼 "다시 알려주는" 용도인 항목은 reduced-motion 에서도 숫자 자체는 즉시 바뀌므로 정보 손실이 없습니다. zip 안의 vanilla·react 코드 모두 같은 미디어쿼리를 그대로 담고 있어, 따로 손대지 않아도 접근성 기본값이 따라옵니다.

FAQ

리액트에서 CSS 애니메이션 대신 애니메이션 라이브러리를 꼭 써야 하나요?

대부분의 마운트·언마운트·상태 전환은 이 글의 9가지처럼 순수 CSS 와 리액트 훅만으로 됩니다. 타임라인을 세밀하게 조율하거나 드래그 제스처처럼 물리 시뮬레이션이 필요할 때만 라이브러리가 더 유리합니다.

@starting-style 은 어떤 브라우저에서 되나요?

최신 크롬·엣지·오페라는 지원하고 사파리·파이어폭스는 버전에 따라 갈립니다. 지원하지 않는 브라우저에서는 트랜지션 없이 바로 나타날 뿐 레이아웃이 깨지지는 않으니, 프로그레시브 인핸스먼트로 써도 안전합니다.

key prop 을 바꾸면 성능에 문제가 없나요?

key 가 바뀔 때마다 컴포넌트가 통째로 새로 생성되므로, 자주 바뀌는 리스트 전체에 매번 새 key 를 주면 비용이 커집니다. 05번처럼 재생 트리거가 필요한 작은 요소 하나에만 쓰는 걸 권합니다. key 로 상태를 초기화하는 원리는 리액트 공식 문서를, 클래스 하나로 상태를 바꾸는 다른 예시는 토글 스위치 9종저장·복사 같은 상태 전환 9종에서 더 볼 수 있습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.