CSS 페이지 전환 애니메이션 9종 — JS 없이, CSS만으로
CSS 페이지 전환 애니메이션은 한 화면이 사라지고 다음 화면이 나타나는 순간을 매끄럽게 잇는 CSS 동작이다. 아홉 개 중 세 개는 흔한 라이브러리 템플릿에서 잘 안 보이는 조합이다.
01 page-transition-cross-fade
02 page-transition-slide-lr
03 page-transition-shared-hero
04 page-transition-circle-reveal
05 page-transition-stack-push
06 page-transition-zoom-card
07 page-transition-tab-crossfade
08 page-transition-list-reorder
09 page-transition-theme-dark-wipe
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 기본 크로스페이드
- 02 좌우 슬라이드
- 03 썸네일→히어로 공유 요소
- 04 클릭 지점 원형 확산
- 05 앱 스택 푸시 전환
- 06 카드→상세 확대 전환
- 07 탭 콘텐츠 크로스페이드
- 08 정렬 시 항목 이동 애니메이션
- 09 다크모드 와이프 전환
아홉 개는 발견되는 순서가 아니라 화면이 실제로 바뀌는 상황 순서로 놓았다. 01~02는 어떤 페이지에도 쓰는 가장 기본적인 크로스페이드·슬라이드이고, 03~06은 클릭한 요소와 다음 화면을 연결하는 전환(공유 요소·원형 확산·스택 푸시·카드 확대)이다. 07~08은 페이지 전체가 아니라 화면 안 콘텐츠 일부만 바뀌는 전환(탭·목록 순서)이고, 09는 화면 전체의 테마가 바뀌는 가장 큰 전환이라 맨 뒤에 뒀다. 페이지를 떠나지 않고 한 겹만 덮는 전환이 필요하면 CSS 모달 팝업 애니메이션이 그 크기를 맡고, 03~06처럼 누른 자리에서 시작하는 전환의 방아쇠가 상단 메뉴라면 CSS 햄버거 메뉴 애니메이션의 아이콘 쪽 변형과 짝을 맞추면 된다.
01기본 크로스페이드
화면에서 일어나는 일
이전 페이지가 옅어지며 사라지고 다음 페이지가 같은 자리에 겹쳐서 나타난다.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">ARTICLE</p>
<h2 class="page__title">이전 글</h2>
<p class="page__body">CSS 변수 3개로 변형 만들기</p>
</section>
<section class="page page--b">
<p class="page__tag">ARTICLE</p>
<h2 class="page__title">다음 글</h2>
<p class="page__body">전환에도 애니메이션 시계가 있다</p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
// ── 두 "페이지" 패널 ───────────────────────
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; }
.page {
position: absolute; inset: 0; box-sizing: border-box;
display: flex; flex-direction: column; justify-content: center; gap: 6px;
padding: clamp(12px, 5vw, 20px); text-align: left;
}
.page--a { background: #fff; color: $subject-ink; }
.page--b { background: $color; color: #fff; }
.page__tag { margin: 0; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1; font-family: inherit; letter-spacing: .12em; opacity: .6; }
.page__title { margin: 0; font-weight: 700; font-size: clamp(14px, 4.2vw, 18px); line-height: 1.3; font-family: inherit; }
.page__body { margin: 0; font-weight: 400; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.5; font-family: inherit; opacity: .82; }
// A 는 보였다 사라지고, B 는 그 자리에서 겹쳐 나타난다. transform·opacity 만 쓴다.
// (실제 전환 속도는 $duration. 데모 루프는 $dur-loop 로 반복 주기를 맞춘다)
.page--a { animation: cross-a $dur-loop $easing infinite; }
.page--b { animation: cross-b $dur-loop $easing infinite; opacity: 0; }
@keyframes cross-a {
0%, 5% { opacity: 1; }
50% { opacity: 0; }
95%, 100% { opacity: 1; }
}
@keyframes cross-b {
0%, 5% { opacity: 0; }
50% { opacity: 1; }
95%, 100% { opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b { animation: none !important; }
.page--a { opacity: 1; }
.page--b { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.page {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: clamp(12px, 5vw, 20px);
text-align: left;
}
.page--a {
background: #fff;
color: #17141a;
}
.page--b {
background: #2f6df6;
color: #fff;
}
.page__tag {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
opacity: 0.6;
}
.page__title {
margin: 0;
font-weight: 700;
font-size: clamp(14px, 4.2vw, 18px);
line-height: 1.3;
font-family: inherit;
}
.page__body {
margin: 0;
font-weight: 400;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
opacity: 0.82;
}
.page--a {
animation: cross-a 2s ease-out infinite;
}
.page--b {
animation: cross-b 2s ease-out infinite;
opacity: 0;
}
@keyframes cross-a {
0%, 5% {
opacity: 1;
}
50% {
opacity: 0;
}
95%, 100% {
opacity: 1;
}
}
@keyframes cross-b {
0%, 5% {
opacity: 0;
}
50% {
opacity: 1;
}
95%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b {
animation: none !important;
}
.page--a {
opacity: 1;
}
.page--b {
opacity: 0;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./CrossFadeTransition.module.scss";
export type CrossFadeTransitionProps = {
/** 어느 페이지가 보이는가. 라우트가 바뀔 때 부모가 이 값을 바꾼다 */
active: "a" | "b";
pageA: ReactNode;
pageB: ReactNode;
/** 전환이 끝나는 시간 (기본 300ms) */
duration?: number;
/** easing (기본 ease-out) */
easing?: string;
/** page--b 배경색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CrossFadeTransition({
active,
pageA,
pageB,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
className = "",
}: CrossFadeTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<section className={styles.pageA}>{pageA}</section>
<section className={styles.pageB}>{pageB}</section>
</div>
);
}01 · 기본 크로스페이드 — 측정 누적면적 31.122% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
css 화면 전환 효과는 어디에 쓰나
내비게이션 사이 전환 대부분의 기본값으로 쓴다.
.page {
position: absolute;
inset: 0;
transition: opacity $duration $easing;
}
.page--a { opacity: 1; }
.page--b { opacity: 0; }
[data-active="b"] .page--a { opacity: 0; }
[data-active="b"] .page--b { opacity: 1; }
02좌우 슬라이드
어떤 움직임인가
다음 페이지가 오른쪽에서 밀고 들어오고 이전 페이지는 왼쪽으로 밀려난다.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">STEP 1</p>
<h2 class="page__title">배송지 입력</h2>
<p class="page__body">주소를 적어주세요</p>
</section>
<section class="page page--b">
<p class="page__tag">STEP 2</p>
<h2 class="page__title">결제 정보</h2>
<p class="page__body">카드 번호를 입력하세요</p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; }
.page {
position: absolute; inset: 0; box-sizing: border-box;
display: flex; flex-direction: column; justify-content: center; gap: 6px;
padding: clamp(12px, 5vw, 20px); text-align: left;
}
.page--a { background: #fff; color: $subject-ink; }
.page--b { background: $color; color: #fff; }
.page__tag { margin: 0; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1; font-family: inherit; letter-spacing: .12em; opacity: .6; }
.page__title { margin: 0; font-weight: 700; font-size: clamp(14px, 4.2vw, 18px); line-height: 1.3; font-family: inherit; }
.page__body { margin: 0; font-weight: 400; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.5; font-family: inherit; opacity: .82; }
// 다음 페이지는 오른쪽에서 밀려 들어오고, 이전 페이지는 왼쪽으로 밀려난다.
.page--a { animation: slide-a $dur-loop $easing infinite; }
.page--b { animation: slide-b $dur-loop $easing infinite; transform: translateX(100%); }
@keyframes slide-a {
0%, 5% { transform: translateX(0); }
50% { transform: translateX(-100%); }
95%, 100% { transform: translateX(0); }
}
@keyframes slide-b {
0%, 5% { transform: translateX(100%); }
50% { transform: translateX(0); }
95%, 100% { transform: translateX(100%); }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b { animation: none !important; }
.page--a { transform: translateX(0); }
.page--b { transform: translateX(100%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.page {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: clamp(12px, 5vw, 20px);
text-align: left;
}
.page--a {
background: #fff;
color: #17141a;
}
.page--b {
background: #17141a;
color: #fff;
}
.page__tag {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
opacity: 0.6;
}
.page__title {
margin: 0;
font-weight: 700;
font-size: clamp(14px, 4.2vw, 18px);
line-height: 1.3;
font-family: inherit;
}
.page__body {
margin: 0;
font-weight: 400;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
opacity: 0.82;
}
.page--a {
animation: slide-a 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.page--b {
animation: slide-b 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
transform: translateX(100%);
}
@keyframes slide-a {
0%, 5% {
transform: translateX(0);
}
50% {
transform: translateX(-100%);
}
95%, 100% {
transform: translateX(0);
}
}
@keyframes slide-b {
0%, 5% {
transform: translateX(100%);
}
50% {
transform: translateX(0);
}
95%, 100% {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b {
animation: none !important;
}
.page--a {
transform: translateX(0);
}
.page--b {
transform: translateX(100%);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./SlideTransition.module.scss";
export type SlideTransitionProps = {
active: "a" | "b";
pageA: ReactNode;
pageB: ReactNode;
/** 전환 시간 (기본 220ms) */
duration?: number;
/** easing (기본 spring 느낌) */
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SlideTransition({
active,
pageA,
pageB,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
className = "",
}: SlideTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<section className={styles.pageA}>{pageA}</section>
<section className={styles.pageB}>{pageB}</section>
</div>
);
}02 · 좌우 슬라이드 — 측정 누적면적 31.126% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
단계형 폼처럼 순서가 있는 화면 사이에 쓴다.
.page {
position: absolute;
inset: 0;
transition: transform $duration $easing;
}
.page--a { transform: translateX(0); }
.page--b { transform: translateX(100%); }
[data-active="b"] .page--a { transform: translateX(-100%); }
[data-active="b"] .page--b { transform: translateX(0); }
03썸네일→히어로 공유 요소
무엇이 보이나
목록의 썸네일 하나가 그대로 늘어나며 다음 페이지의 큰 이미지로 이어진다.

<main class="stage">
<div class="pages is-demo">
<section class="page page--list">
<p class="page__tag">GALLERY</p>
<div class="thumb-row">
<span class="thumb thumb--hero"></span>
<span class="thumb"></span>
<span class="thumb"></span>
</div>
</section>
<div class="hero"></div>
<p class="hero__caption">사진 상세</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; background: #fff; }
.page--list { position: absolute; inset: 0; box-sizing: border-box; padding: clamp(12px, 5vw, 18px); display: flex; flex-direction: column; gap: 10px; }
.page__tag { margin: 0; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1; font-family: inherit; letter-spacing: .12em; color: $subject-ink; opacity: .55; }
.thumb-row { display: flex; gap: 8px; }
.thumb { flex: 1; height: 44px; border-radius: 8px; background: $subject-ink; opacity: .14; }
.thumb--hero { background: $subject-blue; opacity: 1; }
// 목록의 썸네일 하나(:first-child)가 그대로 늘어나며 히어로 이미지가 된다.
// transform-origin 을 썸네일 위치에 두고 scale·translate 만 바꾼다.
.hero {
position: absolute; left: clamp(12px, 5vw, 18px); top: 46px; width: 30%; height: 44px;
border-radius: 8px; background: $subject-blue;
transform-origin: top left; // 규칙8: 공유요소 앵커, 실제론 w/h 애니메이션
animation: hero-grow $dur-loop $easing infinite;
}
.hero__caption {
position: absolute; left: clamp(12px, 5vw, 18px); bottom: 14px; margin: 0;
font-weight: 700; font-size: clamp(11px, 3.4vw, 13px); line-height: 1; font-family: inherit; color: $subject-ink;
animation: caption-in $dur-loop $easing infinite;
}
@keyframes hero-grow {
0%, 40% { transform: scale(1) translate(0, 0); width: 30%; height: 44px; }
70%, 90% { transform: scale(1) translate(0, 0); width: 88%; height: 108px; top: 12px; }
100% { transform: scale(1) translate(0, 0); width: 30%; height: 44px; top: 46px; }
}
@keyframes caption-in {
0%, 55% { opacity: 0; }
75%, 90% { opacity: 1; }
100% { opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.hero, .hero__caption { animation: none !important; }
.hero__caption { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff;
}
.page--list {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
display: flex;
flex-direction: column;
gap: 10px;
}
.page__tag {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
color: #17141a;
opacity: 0.55;
}
.thumb-row {
display: flex;
gap: 8px;
}
.thumb {
flex: 1;
height: 44px;
border-radius: 8px;
background: #17141a;
opacity: 0.14;
}
.thumb--hero {
background: #2f6df6;
opacity: 1;
}
.hero {
position: absolute;
left: clamp(12px, 5vw, 18px);
top: 46px;
width: 30%;
height: 44px;
border-radius: 8px;
background: #2f6df6;
transform-origin: top left;
animation: hero-grow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.hero__caption {
position: absolute;
left: clamp(12px, 5vw, 18px);
bottom: 14px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3.4vw, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
animation: caption-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes hero-grow {
0%, 40% {
transform: scale(1) translate(0, 0);
width: 30%;
height: 44px;
}
70%, 90% {
transform: scale(1) translate(0, 0);
width: 88%;
height: 108px;
top: 12px;
}
100% {
transform: scale(1) translate(0, 0);
width: 30%;
height: 44px;
top: 46px;
}
}
@keyframes caption-in {
0%, 55% {
opacity: 0;
}
75%, 90% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.hero, .hero__caption {
animation: none !important;
}
.hero__caption {
opacity: 0;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./SharedHeroTransition.module.scss";
export type SharedHeroTransitionProps = {
/** "list": 썸네일 격자, "hero": 확대된 상세 */
active: "list" | "hero";
caption: ReactNode;
/** 확대가 끝나는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SharedHeroTransition({
active,
caption,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
className = "",
}: SharedHeroTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<div className={styles.thumbRow}>
<span className={styles.thumb} />
<span className={styles.thumb} />
</div>
<div className={styles.hero} />
<p className={styles.caption}>{caption}</p>
</div>
);
}03 · 썸네일→히어로 공유 요소 — 측정 누적면적 17.511% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
width·height·top을 직접 바꾸는 대신 같은 요소 하나의 크기만 옮긴다.
.hero {
position: absolute;
left: 18px; top: 60px;
width: 30%; height: 44px;
transform-origin: top left;
transition: width $duration $easing, height $duration $easing, top $duration $easing;
}
[data-active="hero"] .hero {
width: 88%; height: 62%; top: 14px;
}
04클릭 지점 원형 확산
화면에서 일어나는 일
클릭한 지점에서 원이 퍼지며 다음 페이지를 덮는다.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">SETTINGS</p>
<h2 class="page__title">라이트 모드</h2>
<p class="page__body">눈에 밝은 화면</p>
</section>
<section class="page page--b page--circle">
<p class="page__tag">SETTINGS</p>
<h2 class="page__title">다크 모드</h2>
<p class="page__body">눈에 편한 화면</p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; }
.page {
position: absolute; inset: 0; box-sizing: border-box;
display: flex; flex-direction: column; justify-content: center; gap: 6px;
padding: clamp(12px, 5vw, 20px); text-align: left;
}
.page--a { background: #fff; color: $subject-ink; }
.page--b { background: $subject-ink; color: #fff; }
.page__tag { margin: 0; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1; font-family: inherit; letter-spacing: .12em; opacity: .6; }
.page__title { margin: 0; font-weight: 700; font-size: clamp(14px, 4.2vw, 18px); line-height: 1.3; font-family: inherit; }
.page__body { margin: 0; font-weight: 400; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.5; font-family: inherit; opacity: .82; }
// 클릭 지점(여기서는 우측 상단 토글 자리)에서 원이 자라며 다음 페이지를 덮는다.
// clip-path 는 레이아웃을 다시 계산하지 않아 transform 과 같은 값으로 취급한다.
.page--circle {
clip-path: circle(0% at 88% 14%);
animation: circle-grow $dur-loop $easing infinite;
}
@keyframes circle-grow {
0%, 5% { clip-path: circle(0% at 88% 14%); }
50% { clip-path: circle(150% at 88% 14%); }
95%, 100% { clip-path: circle(0% at 88% 14%); }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.page--circle { animation: none !important; clip-path: circle(0% at 88% 14%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.page {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: clamp(12px, 5vw, 20px);
text-align: left;
}
.page--a {
background: #fff;
color: #17141a;
}
.page--b {
background: #17141a;
color: #fff;
}
.page__tag {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
opacity: 0.6;
}
.page__title {
margin: 0;
font-weight: 700;
font-size: clamp(14px, 4.2vw, 18px);
line-height: 1.3;
font-family: inherit;
}
.page__body {
margin: 0;
font-weight: 400;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
opacity: 0.82;
}
.page--circle {
clip-path: circle(0% at 88% 14%);
animation: circle-grow 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes circle-grow {
0%, 5% {
clip-path: circle(0% at 88% 14%);
}
50% {
clip-path: circle(150% at 88% 14%);
}
95%, 100% {
clip-path: circle(0% at 88% 14%);
}
}
@media (prefers-reduced-motion: reduce) {
.page--circle {
animation: none !important;
clip-path: circle(0% at 88% 14%);
}
}import type { CSSProperties, ReactNode, MouseEvent } from "react";
import { useState } from "react";
import styles from "./CircleRevealTransition.module.scss";
export type CircleRevealTransitionProps = {
active: "a" | "b";
pageA: ReactNode;
pageB: ReactNode;
/** 원이 다 퍼지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--x"?: string; "--y"?: string };
export default function CircleRevealTransition({
active,
pageA,
pageB,
duration = 600,
easing = "cubic-bezier(.2,1.4,.4,1)",
className = "",
}: CircleRevealTransitionProps) {
const [origin, setOrigin] = useState({ x: "88%", y: "14%" });
const remember = (e: MouseEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setOrigin({ x: `${((e.clientX - r.left) / r.width) * 100}%`, y: `${((e.clientY - r.top) / r.height) * 100}%` });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--x": origin.x, "--y": origin.y };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active} onClickCapture={remember}>
<section className={styles.pageA}>{pageA}</section>
<section className={styles.pageB}>{pageB}</section>
</div>
);
}04 · 클릭 지점 원형 확산 — 측정 누적면적 30.981% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
클릭 좌표는 JS가 CSS 변수 --x --y로 넘긴다.
.page--b {
clip-path: circle(0% at var(--x) var(--y));
transition: clip-path $duration $easing;
}
[data-active="b"] .page--b {
clip-path: circle(150% at var(--x) var(--y));
}
05앱 스택 푸시 전환
어떤 움직임인가
새 화면이 오른쪽에서 쌓이듯 밀려 들어오고, 뒤 화면은 살짝 줄어들며 어두워진다.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">MENU</p>
<h2 class="page__title">설정</h2>
<p class="page__body">계정 · 알림 · 결제</p>
</section>
<section class="page page--b">
<p class="page__tag">MENU > PUSH</p>
<h2 class="page__title">알림 설정</h2>
<p class="page__body">푸시 · 이메일 · 문자</p>
</section>
<div class="dim"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; }
.page {
position: absolute; inset: 0; box-sizing: border-box;
display: flex; flex-direction: column; justify-content: center; gap: 6px;
padding: clamp(12px, 5vw, 20px); text-align: left;
}
.page--a { background: #fff; color: $subject-ink; }
.page--b { background: $color; color: $subject-cream; }
.page__tag { margin: 0; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1; font-family: inherit; letter-spacing: .12em; }
.page__title { margin: 0; font-weight: 700; font-size: clamp(14px, 4.2vw, 18px); line-height: 1.3; font-family: inherit; }
.page__body { margin: 0; font-weight: 400; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.5; font-family: inherit; }
.dim { position: absolute; inset: 0; background: $subject-ink; opacity: 0; pointer-events: none; }
// 새 화면이 오른쪽에서 쌓이듯 밀려 들어오고, 이전 화면은 살짝 줄어들며 어두워진다.
.page--a { animation: push-back $dur-loop $easing infinite; transform-origin: left center; } // 규칙8: 새 화면이 오른쪽에서 들어오니 이전 화면은 왼쪽 변을 축으로 물러난다
.page--b { animation: push-front $dur-loop $easing infinite; transform: translateX(100%); }
.dim { animation: push-dim $dur-loop $easing infinite; }
@keyframes push-back {
0%, 5% { transform: scale(1); }
50% { transform: scale(.94); }
95%, 100% { transform: scale(1); }
}
@keyframes push-front {
0%, 5% { transform: translateX(100%); }
50% { transform: translateX(0); }
95%, 100% { transform: translateX(100%); }
}
@keyframes push-dim {
0%, 5% { opacity: 0; }
50% { opacity: .18; }
95%, 100% { opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b, .dim { animation: none !important; }
.page--b { transform: translateX(100%); }
.dim { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.page {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: clamp(12px, 5vw, 20px);
text-align: left;
}
.page--a {
background: #fff;
color: #17141a;
}
.page--b {
background: #0a4fe8;
color: #fff7e6;
}
.page__tag {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
}
.page__title {
margin: 0;
font-weight: 700;
font-size: clamp(14px, 4.2vw, 18px);
line-height: 1.3;
font-family: inherit;
}
.page__body {
margin: 0;
font-weight: 400;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
}
.dim {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.page--a {
animation: push-back 2s ease-out infinite;
transform-origin: left center;
}
.page--b {
animation: push-front 2s ease-out infinite;
transform: translateX(100%);
}
.dim {
animation: push-dim 2s ease-out infinite;
}
@keyframes push-back {
0%, 5% {
transform: scale(1);
}
50% {
transform: scale(0.94);
}
95%, 100% {
transform: scale(1);
}
}
@keyframes push-front {
0%, 5% {
transform: translateX(100%);
}
50% {
transform: translateX(0);
}
95%, 100% {
transform: translateX(100%);
}
}
@keyframes push-dim {
0%, 5% {
opacity: 0;
}
50% {
opacity: 0.18;
}
95%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b, .dim {
animation: none !important;
}
.page--b {
transform: translateX(100%);
}
.dim {
opacity: 0;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./StackPushTransition.module.scss";
export type StackPushTransitionProps = {
/** "root": 뒤 화면만, "pushed": 새 화면이 쌓인 상태 */
active: "root" | "pushed";
root: ReactNode;
pushed: ReactNode;
/** 새 화면이 밀려 들어오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StackPushTransition({
active,
root,
pushed,
duration = 300,
easing = "ease-out",
color = "#0a4fe8",
className = "",
}: StackPushTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<section className={styles.root}>{root}</section>
<div className={styles.dim} />
<section className={styles.pushed}>{pushed}</section>
</div>
);
}05 · 앱 스택 푸시 전환 — 측정 누적면적 31.126% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
모바일 웹앱의 설정 화면 이동에 쓴다.
.root { transition: transform $duration $easing; }
.pushed {
position: absolute; inset: 0;
transform: translateX(100%);
transition: transform $duration $easing;
}
.dim { opacity: 0; transition: opacity $duration $easing; }
[data-active="pushed"] .root { transform: scale(.94); }
[data-active="pushed"] .pushed { transform: translateX(0); }
[data-active="pushed"] .dim { opacity: .18; }
06카드→상세 확대 전환
무엇이 보이나
클릭한 카드가 화면 전체 크기로 확대되며 상세 페이지가 된다.

<main class="stage">
<div class="pages is-demo">
<section class="page page--grid">
<p class="page__tag">NEWS</p>
<div class="grid">
<span class="cell cell--focus"></span>
<span class="cell"></span>
<span class="cell"></span>
<span class="cell"></span>
</div>
</section>
<div class="detail">
<p class="detail__body">기사 본문이 이 자리를 채운다</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; background: #fff; }
.page--grid { position: absolute; inset: 0; box-sizing: border-box; padding: clamp(12px, 5vw, 18px); display: flex; flex-direction: column; gap: 8px; }
.page__tag { margin: 0; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1; font-family: inherit; letter-spacing: .12em; color: $subject-ink; opacity: .55; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; flex: 1; }
.cell { border-radius: 8px; background: $subject-ink; opacity: .12; }
.cell--focus { background: $color; opacity: 1; }
// 클릭한 카드(cell--focus)가 화면을 가득 채우도록 transform 으로만 확대한다.
.cell--focus {
position: relative;
animation: zoom-card $dur-loop $easing infinite;
}
.detail {
position: absolute; inset: 0; background: $color; color: #fff;
display: flex; align-items: flex-end; box-sizing: border-box; padding: clamp(12px, 5vw, 18px);
opacity: 0;
animation: zoom-detail $dur-loop $easing infinite;
}
.detail__body { margin: 0; font-weight: 400; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.5; font-family: inherit; }
@keyframes zoom-card {
0%, 40% { transform: scale(1); }
70% { transform: scale(4); }
100% { transform: scale(1); }
}
@keyframes zoom-detail {
0%, 55% { opacity: 0; }
75%, 90% { opacity: 1; }
100% { opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.cell--focus, .detail { animation: none !important; }
.detail { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff;
}
.page--grid {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
display: flex;
flex-direction: column;
gap: 8px;
}
.page__tag {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
color: #17141a;
opacity: 0.55;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
flex: 1;
}
.cell {
border-radius: 8px;
background: #17141a;
opacity: 0.12;
}
.cell--focus {
background: #17141a;
opacity: 1;
}
.cell--focus {
position: relative;
animation: zoom-card 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.detail {
position: absolute;
inset: 0;
background: #17141a;
color: #fff;
display: flex;
align-items: flex-end;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
opacity: 0;
animation: zoom-detail 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.detail__body {
margin: 0;
font-weight: 400;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
}
@keyframes zoom-card {
0%, 40% {
transform: scale(1);
}
70% {
transform: scale(4);
}
100% {
transform: scale(1);
}
}
@keyframes zoom-detail {
0%, 55% {
opacity: 0;
}
75%, 90% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cell--focus, .detail {
animation: none !important;
}
.detail {
opacity: 0;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ZoomCardTransition.module.scss";
export type ZoomCardTransitionProps = {
/** "grid": 카드 목록, "detail": 확대된 상세 */
active: "grid" | "detail";
detail: ReactNode;
/** 확대가 끝나는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ZoomCardTransition({
active,
detail,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
className = "",
}: ZoomCardTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<div className={styles.grid}>
<span className={styles.cellFocus} />
<span className={styles.cell} />
<span className={styles.cell} />
<span className={styles.cell} />
</div>
<div className={styles.detail}>{detail}</div>
</div>
);
}06 · 카드→상세 확대 전환 — 측정 누적면적 27.268% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
갤러리·뉴스 카드 목록에 쓴다.
.cell-focus { transition: transform $duration $easing; }
.detail {
position: absolute; inset: 0;
opacity: 0;
transition: opacity $duration $easing;
}
[data-active="detail"] .cell-focus { transform: scale(4); }
[data-active="detail"] .detail { opacity: 1; }
07탭 콘텐츠 크로스페이드
화면에서 일어나는 일
탭을 바꾸면 이전 콘텐츠는 사라지고 새 콘텐츠가 8px 위에서 겹쳐 내려온다.

<main class="stage">
<div class="pages is-demo">
<div class="tabs">
<span class="tab tab--on">일반</span>
<span class="tab">보안</span>
</div>
<section class="page page--a">
<p class="page__body">언어 · 테마 · 알림 빈도를 여기서 바꾼다</p>
</section>
<section class="page page--b">
<p class="page__body">비밀번호 · 2단계 인증을 여기서 바꾼다</p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; background: #fff; }
.tabs { position: absolute; top: 0; left: 0; right: 0; display: flex; gap: clamp(10px, 4vw, 16px); padding: clamp(10px, 4vw, 14px) clamp(12px, 5vw, 18px) 0; box-sizing: border-box; }
.tab { font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1; font-family: inherit; color: $subject-ink; opacity: .4; padding-bottom: 8px; }
.tab--on { opacity: 1; color: $subject-ink; border-bottom: 2px solid $color; }
.page {
position: absolute; left: 0; right: 0; bottom: 0; top: 40px; box-sizing: border-box;
padding: clamp(12px, 5vw, 18px); display: flex; align-items: center;
}
.page__body { margin: 0; font-weight: 400; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.5; font-family: inherit; color: $subject-ink; }
// 탭이 바뀌면 이전 콘텐츠는 사라지고 새 콘텐츠가 8px 위에서 겹쳐 내려온다.
.page--a { animation: tab-a $dur-loop $easing infinite; }
.page--b { animation: tab-b $dur-loop $easing infinite; opacity: 0; transform: translateY(8px); }
@keyframes tab-a {
0%, 5% { opacity: 1; transform: translateY(0); }
50% { opacity: 0; transform: translateY(-8px); }
95%, 100% { opacity: 1; transform: translateY(0); }
}
@keyframes tab-b {
0%, 5% { opacity: 0; transform: translateY(8px); }
50% { opacity: 1; transform: translateY(0); }
95%, 100% { opacity: 0; transform: translateY(8px); }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b { animation: none !important; }
.page--a { opacity: 1; transform: translateY(0); }
.page--b { opacity: 0; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff;
}
.tabs {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
gap: clamp(10px, 4vw, 16px);
padding: clamp(10px, 4vw, 14px) clamp(12px, 5vw, 18px) 0;
box-sizing: border-box;
}
.tab {
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
opacity: 0.4;
padding-bottom: 8px;
}
.tab--on {
opacity: 1;
color: #17141a;
border-bottom: 2px solid #fff7e6;
}
.page {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 40px;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
display: flex;
align-items: center;
}
.page__body {
margin: 0;
font-weight: 400;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
}
.page--a {
animation: tab-a 2s ease-out infinite;
}
.page--b {
animation: tab-b 2s ease-out infinite;
opacity: 0;
transform: translateY(8px);
}
@keyframes tab-a {
0%, 5% {
opacity: 1;
transform: translateY(0);
}
50% {
opacity: 0;
transform: translateY(-8px);
}
95%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tab-b {
0%, 5% {
opacity: 0;
transform: translateY(8px);
}
50% {
opacity: 1;
transform: translateY(0);
}
95%, 100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b {
animation: none !important;
}
.page--a {
opacity: 1;
transform: translateY(0);
}
.page--b {
opacity: 0;
transform: translateY(0);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./TabCrossfadeTransition.module.scss";
export type TabCrossfadeTransitionProps = {
active: "a" | "b";
tabA: ReactNode;
tabB: ReactNode;
panelA: ReactNode;
panelB: ReactNode;
/** 전환 시간 (기본 200ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TabCrossfadeTransition({
active,
tabA,
tabB,
panelA,
panelB,
duration = 200,
easing = "ease-out",
color = "#fff7e6",
className = "",
}: TabCrossfadeTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<div className={styles.tabs}>
<span className={styles.tab} data-on={active === "a"}>{tabA}</span>
<span className={styles.tab} data-on={active === "b"}>{tabB}</span>
</div>
<section className={styles.panelA}>{panelA}</section>
<section className={styles.panelB}>{panelB}</section>
</div>
);
}07 · 탭 콘텐츠 크로스페이드 — 측정 누적면적 2.663% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
설정·대시보드의 탭 콘텐츠에 쓴다.
.panel {
transition: opacity $duration $easing, transform $duration $easing;
}
.panel--a { opacity: 1; transform: translateY(0); }
.panel--b { opacity: 0; transform: translateY(8px); }
[data-active="b"] .panel--a { opacity: 0; transform: translateY(-8px); }
[data-active="b"] .panel--b { opacity: 1; transform: translateY(0); }
08정렬 시 항목 이동 애니메이션
어떤 움직임인가
정렬 기준을 바꾸면 순위가 바뀐 항목만 이동한 거리만큼 부드럽게 움직인다.

<main class="stage">
<div class="pages is-demo">
<p class="page__tag">RANKING</p>
<div class="rows">
<div class="row row--1"><span class="row__rank">1</span><span class="row__name">가격 낮은 순</span></div>
<div class="row row--2"><span class="row__rank">2</span><span class="row__name">평점 높은 순</span></div>
<div class="row row--3"><span class="row__rank">3</span><span class="row__name">신상품 순</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue-deep;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; background: #fff; box-sizing: border-box; padding: clamp(12px, 5vw, 18px); }
.page__tag { margin: 0 0 8px; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1; font-family: inherit; letter-spacing: .12em; color: $subject-ink; opacity: .72; }
.rows { position: relative; height: 96px; }
.row {
position: absolute; left: 0; right: 0; height: 28px; border-radius: 8px;
background: rgba(0, 0, 0, .04); display: flex; align-items: center; gap: 10px;
padding: 0 12px; box-sizing: border-box; font-weight: 600; font-size: clamp(11px, 3.2vw, 13px); line-height: 1; font-family: inherit; color: $subject-ink;
}
.row__rank { color: $color; font-weight: 700; }
.row--1 { top: 0; }
.row--2 { top: 34px; }
.row--3 { top: 68px; }
// 1위와 2위가 자리를 바꾼다. FLIP 처럼 최종 위치 차이만큼만 translateY 로 옮긴다.
.row--1 { animation: swap-down $dur-loop $easing infinite; }
.row--2 { animation: swap-up $dur-loop $easing infinite; }
@keyframes swap-down {
0%, 5% { transform: translateY(0); }
50% { transform: translateY(34px); }
95%, 100% { transform: translateY(0); }
}
@keyframes swap-up {
0%, 5% { transform: translateY(0); }
50% { transform: translateY(-34px); }
95%, 100% { transform: translateY(0); }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.row--1, .row--2 { animation: none !important; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
}
.page__tag {
margin: 0 0 8px;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
color: #17141a;
opacity: 0.72;
}
.rows {
position: relative;
height: 96px;
}
.row {
position: absolute;
left: 0;
right: 0;
height: 28px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
box-sizing: border-box;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.row__rank {
color: #0a4fe8;
font-weight: 700;
}
.row--1 {
top: 0;
}
.row--2 {
top: 34px;
}
.row--3 {
top: 68px;
}
.row--1 {
animation: swap-down 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.row--2 {
animation: swap-up 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes swap-down {
0%, 5% {
transform: translateY(0);
}
50% {
transform: translateY(34px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes swap-up {
0%, 5% {
transform: translateY(0);
}
50% {
transform: translateY(-34px);
}
95%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.row--1, .row--2 {
animation: none !important;
transform: translateY(0);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ListReorderTransition.module.scss";
export type ListReorderTransitionProps = {
/** "asc": 기본 순서, "swapped": 1·2위가 바뀐 순서 */
active: "asc" | "swapped";
row1: ReactNode;
row2: ReactNode;
row3: ReactNode;
/** 이동이 끝나는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ListReorderTransition({
active,
row1,
row2,
row3,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8",
className = "",
}: ListReorderTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<div className={styles.rows}>
<div className={styles.row1}>{row1}</div>
<div className={styles.row2}>{row2}</div>
<div className={styles.row3}>{row3}</div>
</div>
</div>
);
}08 · 정렬 시 항목 이동 애니메이션 — 측정 누적면적 2.935% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
FLIP 기법으로 이동 거리를 미리 계산해 transform 하나로 옮긴다.
.row {
transition: transform $duration $easing;
}
.row-1 { top: 0; }
.row-2 { top: 34px; }
[data-active="swapped"] .row-1 { transform: translateY(34px); }
[data-active="swapped"] .row-2 { transform: translateY(-34px); }
09다크모드 와이프 전환
무엇이 보이나
화면 모서리에서 어두운 색이 대각선으로 번지며 다크모드로 바뀐다.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">HOME</p>
<h2 class="page__title">라이트</h2>
<p class="page__body">기본 테마</p>
</section>
<section class="page page--b page--wipe">
<p class="page__tag">HOME</p>
<h2 class="page__title">다크</h2>
<p class="page__body">눈에 편한 테마</p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; }
.page {
position: absolute; inset: 0; box-sizing: border-box;
display: flex; flex-direction: column; justify-content: center; gap: 6px;
padding: clamp(12px, 5vw, 20px); text-align: left;
}
.page--a { background: #fff; color: $subject-ink; }
.page--b { background: $subject-ink; color: #fff; }
.page__tag { margin: 0; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1; font-family: inherit; letter-spacing: .12em; opacity: .6; }
.page__title { margin: 0; font-weight: 700; font-size: clamp(14px, 4.2vw, 18px); line-height: 1.3; font-family: inherit; }
.page__body { margin: 0; font-weight: 400; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.5; font-family: inherit; opacity: .82; }
// 모서리에서 대각선으로 자라는 clip-path 쐐기가 다크 페이지를 덮는다.
.page--wipe {
clip-path: polygon(0 0, 0 0, 0 0);
animation: dark-wipe $dur-loop $easing infinite;
}
@keyframes dark-wipe {
0%, 35% { clip-path: polygon(0 0, 0 0, 0 0); }
75%, 90% { clip-path: polygon(0 0, 200% 0, 0 200%); }
100% { clip-path: polygon(0 0, 0 0, 0 0); }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.page--wipe { animation: none !important; clip-path: polygon(0 0, 0 0, 0 0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.page {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: clamp(12px, 5vw, 20px);
text-align: left;
}
.page--a {
background: #fff;
color: #17141a;
}
.page--b {
background: #17141a;
color: #fff;
}
.page__tag {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
opacity: 0.6;
}
.page__title {
margin: 0;
font-weight: 700;
font-size: clamp(14px, 4.2vw, 18px);
line-height: 1.3;
font-family: inherit;
}
.page__body {
margin: 0;
font-weight: 400;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
opacity: 0.82;
}
.page--wipe {
clip-path: polygon(0 0, 0 0, 0 0);
animation: dark-wipe 2s ease-out infinite;
}
@keyframes dark-wipe {
0%, 35% {
clip-path: polygon(0 0, 0 0, 0 0);
}
75%, 90% {
clip-path: polygon(0 0, 200% 0, 0 200%);
}
100% {
clip-path: polygon(0 0, 0 0, 0 0);
}
}
@media (prefers-reduced-motion: reduce) {
.page--wipe {
animation: none !important;
clip-path: polygon(0 0, 0 0, 0 0);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ThemeWipeTransition.module.scss";
export type ThemeWipeTransitionProps = {
active: "light" | "dark";
light: ReactNode;
dark: ReactNode;
/** 와이프가 다 덮는 시간 (기본 600ms) */
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ThemeWipeTransition({
active,
light,
dark,
duration = 600,
easing = "ease-out",
className = "",
}: ThemeWipeTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<section className={styles.light}>{light}</section>
<section className={styles.dark}>{dark}</section>
</div>
);
}09 · 다크모드 와이프 전환 — 측정 누적면적 31.016% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
clip-path 쐐기 하나로 레이아웃을 다시 계산하지 않는다.
.dark {
clip-path: polygon(0 0, 0 0, 0 0);
transition: clip-path $duration $easing;
}
[data-active="dark"] .dark {
clip-path: polygon(0 0, 200% 0, 0 200%);
}
어디서 깨지는가 — 함정 셋
브라우저 차이
첫 번째는 브라우저 차이다. 위 아홉 개는 전부 클래스·data 속성 토글과 transition·clip-path만으로 동작해 어떤 최신 브라우저에서도 같게 움직인다. 다만 실제 서비스에서 더 매끄러운 전환을 원하면 Chrome·Edge가 지원하는 View Transitions API로 옮길 수 있는데, Safari·Firefox는 이 글을 쓰는 시점까지 지원이 갈린다. MDN View Transitions API와 caniuse에서 최신 지원 현황을 확인할 수 있다.
쌓임 순서
두 번째는 css 등장 애니메이션이 겹칠 때 두 페이지의 쌓임 순서다. 04 원형 확산과 09 다크모드 와이프는 뒤 페이지가 이미 DOM에 있고 앞 페이지가 clip-path로 걷히는 방식이라, z-index를 안 정해두면 브라우저마다 어느 페이지가 위에 그려질지 달라진다. 이 zip의 아홉 개는 전부 나중에 오는 요소가 위로 오는 문서 순서 그대로 두고 z-index를 새로 주지 않았다.
무대에서 실측한 것
세 번째는 480×300 무대에서 실측한 결과다. 03 공유 요소 전환은 썸네일이 두 프레임 만에 히어로 크기까지 벌어져 중간 단계가 안 보인 적이 있어 확산 구간을 40~90%로 늘렸고, 그렇게 다듬은 아홉 개 파일의 압축 비밀번호는 amqzkrw2이다.
같은 확대·확산을 첫 화면 한 판에서 크게 쓰면 중간 단계가 더 길게 보여야 하는데, 그 큰 판의 등장은 CSS 히어로 섹션 애니메이션에 모아 뒀다.
접근성
동작 줄이기를 켰을 때
시스템에서 동작 줄이기를 켠 사용자에게는 아홉 개 전부 transition을 끄고 전환 뒤의 최종 상태만 바로 보여준다. 01·02·07은 다음 페이지를, 03·06은 확대된 상세를, 04·09는 덮인 다크 화면을, 05는 쌓인 새 화면을, 08은 바뀐 순서를 그대로 고정한다. 세부는 MDN prefers-reduced-motion에서 볼 수 있다.
이 자리에서 다른 css 애니메이션 예시도 볼 수 있는 카테고리 페이지와, 사이트 소개를 담은 소개 페이지로 이어진다.
FAQ
css 애니메이션 효과를 내려면 View Transitions API를 꼭 써야 하나요?
그렇지 않습니다. 위 아홉 개는 View Transitions API 없이 클래스·data 속성 토글과 CSS transition·clip-path만으로 동작합니다. API는 브라우저가 전환 스냅샷을 대신 찍어주는 지름길일 뿐이고, 없어도 같은 결과를 손으로 만들 수 있습니다.
React 라우터가 바뀔 때 이 컴포넌트를 어떻게 트리거하나요?
react/ 폴더의 아홉 개 컴포넌트는 모두 active props 하나로 어느 화면이 보이는지 정합니다. 라우터의 현재 경로를 이 props에 그대로 연결하면 경로가 바뀔 때마다 transition을 자동으로 실행합니다. 경로가 아니라 한 영역이 접혔다 펴지는 정도라면 라우터를 거칠 일이 없고, 그 경우는 CSS 아코디언 애니메이션처럼 높이 대신 다른 값을 쓰는 방식이 맞습니다.
전환 중에 뒤로가기를 누르면 애니메이션이 끊기나요?
끊길 수 있습니다. 이 zip의 데모는 전환이 끝나기 전에 active 값이 다시 바뀌면 CSS transition이 새 목표값을 향해 그 자리에서 방향을 바꾸므로 완전히 끊기지는 않지만, 04·09처럼 clip-path를 쓰는 경우 중간에 반대 방향으로 줄어드는 모양이 부자연스러울 수 있어 빠른 연타에는 전환 시간을 더 짧게 두는 걸 권합니다.









