【JS最小・CSSだけ】css ページ遷移 アニメーション9選
css ページ遷移 アニメーションは、2枚の画面要素を opacity や transform、clip-path だけで重ねて、ライブラリなしにページ間の切り替えを表現する手法です。この記事では9種類を選び、グリッドではそれぞれ2秒ごとに前後の画面が自動で入れ替わります。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 既定のクロスフェード
- 02 左右スライド
- 03 サムネイル→ヒーロー要素の共有
- 04 クリック地点から円形に広がる
- 05 アプリ風スタックプッシュ
- 06 カード→詳細へズーム
- 07 タブコンテンツのクロスフェード
- 08 並び替え時のアイテム移動
- 09 ダークモードへのワイプ切替
並び順は実装の難しさではなく、実際のサイトでよく使う場面の順に並べています。最初の3つは記事や商品の一般的な切り替え(01〜03)、次の3つはクリックした場所やカードを起点にした切り替え(04〜06)、最後の3つはタブや並び替え、テーマ切替のような UI 内の細かい遷移です(07〜09)。HTML と CSS だけで完結させるため、JavaScript のファイルは zip に1つも入っていません。項目ごとに要点となるコードだけを載せ、完全なファイルは zip の中です。気に入ったものがあれば zip をダウンロードして、自分のページで試してください。 ページを離れず一枚だけ覆う切り替えならCSS モーダル アニメーション9選がその大きさを担い、03〜06のように押した場所から始まる切り替えのきっかけが上部メニューであれば、css ハンバーガーメニュー アニメーション9選のアイコン側の変形と対にできます。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
css トランジション アニメーションの使いどころ
opacity だけを動かすので、レイアウトの再計算が起きません。ブログ記事の遷移や一般的なページ移動に向いています。
.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; }
}
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
2枚とも同じ transform: translateX を逆方向に動かすだけで、方向を反転すれば「戻る」動作にもそのまま使えます。ステップ形式のフォームやモバイルの戻る・進むに向いています。
.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%); }
}
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
デモでは同じ要素を width・height・top で動かしていますが、実際のページ遷移では View Transitions API の view-transition-name を使うと、別々の文書間でもブラウザが自動で補間してくれます。商品詳細やポートフォリオ詳細への遷移に向いています。
.hero {
position: absolute; left: clamp(12px, 5vw, 18px); top: 46px; width: 30%; height: 44px;
transform-origin: top left;
animation: hero-grow $dur-loop $easing infinite;
}
@keyframes hero-grow {
0%, 40% { width: 30%; height: 44px; }
70%, 90% { width: 88%; height: 108px; top: 12px; }
100% { width: 30%; height: 44px; top: 46px; }
}
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
width や left ではなく clip-path の circle() だけを動かすので、コンポジター側の処理で済み、レイアウトの再計算が起きません。ダークモード切替やテーマ切替ボタンに向いています。
.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%); }
}
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">プッシュ・メール・SMS</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
3つの要素(前面・背面・暗幕)を同時に動かしても、それぞれ独立した animation なので仕組みは崩れません。モバイルWebアプリや設定画面の遷移に向いています。
.page--a { animation: push-back $dur-loop $easing infinite; transform-origin: left center; }
.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-dim {
0%, 5% { opacity: 0; }
50% { opacity: .18; }
95%, 100% { opacity: 0; }
}
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
cell--focus を scale(4) まで広げてから detail を重ねて表示するので、拡大の最中に文字が歪んでも、最終状態では別の要素に差し替わって見えます。ギャラリーやニュースカード一覧に向いています。
.cell--focus { animation: zoom-card $dur-loop $easing infinite; }
.detail {
position: absolute; inset: 0; opacity: 0;
animation: zoom-detail $dur-loop $easing infinite;
}
@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; }
}
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
01の単純なクロスフェードと違い、opacity と transform: translateY を同時に動かすことで、切り替えに向きの手がかりを加えています。設定タブやダッシュボードのタブコンテンツに向いています。
.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); }
}
08並び替え時のアイテム移動
どんな動きか
並び替えを変えると、1位と2位の項目が新しい位置へ滑らかに入れ替わります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
FLIP 技法の考え方どおり、実際に動くのは34pxという最終位置の差分だけで、要素の再配置そのものは CSS に任せています。価格の並び替えやランキング一覧に向いています。
.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); }
}
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
clip-path の polygon() で三角形を0から画面の外まで広げるだけなので、切り替える色の面積が大きくても計算量は変わりません。ダークモード切替ボタンや設定画面に向いています。
.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); }
}
どこで崩れるか — 落とし穴
3つのプロパティだけの理由
9つとも transform・opacity・clip-path のどれかだけで動く理由は、この3つがブラウザのコンポジター側で処理され、レイアウトの再計算(リフロー)を起こさないからです。MDN の CSS transitions ガイド にあるとおり、width や top、left をアニメーションさせると毎フレームでレイアウトが計算し直され、スクロールやタップの反応が遅れて見えます。02のスライドと05のスタックプッシュで最初につまずいたのがここで、transform: translateX の代わりに left の数値を動かしていた版では、480pxの舞台でもコマ落ちが出ていました。 同じ拡大を最初の一画面いっぱいで使うと途中経過をもっと長く見せる必要が出てきます。その大きさの登場はCSSヒーローセクション アニメーション9選にまとめてあります。
ズームで文字がぼやける
もう一つの落とし穴は06のズームカードです。scale(4) まで拡大する間、拡大された要素の内側にある文字がぼやけて見える瞬間があります。ここは動きを隠すためではなく、detail という別要素を透明度だけで重ねて、拡大が終わるタイミングで文字を差し替えることで解決しました。修正済みの9ファイルを開くには amqzkrw2 という文字列が必要です。下のカードから zip を取ってください。
ブラウザ間の違い
3つ目はブラウザ間の違いです。03のような遷移を同一ドキュメント内で View Transitions API として実装する場合、対応状況は環境ごとに差があります。caniuse の View Transitions API によると、同一ドキュメント内の遷移は Chrome 111 以降・Safari 18 以降・Firefox 144 以降で使えますが、それより前のブラウザでは何も起きないため、この記事のデモのように transform・opacity・clip-path だけで組んだ版を常に用意しておくと安全です。
アクセシビリティ
動きを減らす設定のとき
9つとも prefers-reduced-motion: reduce で animation: none になり、動きが止まります。04の円形拡散と09のワイプは、動きを止めると同時に最終状態の clip-path をそのまま指定してあるので、次のページが完全に隠れたまま止まることはありません。08の並び替えだけは、動きを止めても要素の最終位置は変わらないため、順位の入れ替わり自体は瞬時に反映されます。
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b { animation: none !important; }
.page--circle { animation: none !important; clip-path: circle(0% at 88% 14%); }
}
ページ遷移以外の css トランジション サンプルは CSS アニメーションのカテゴリ に、このサイトについては 紹介ページ にまとめています。
FAQ
css ページ遷移 アニメーションは JavaScript なしで作れますか
はい、作れます。この記事の9つはすべて CSS の animation と @keyframes だけで実装していて、JavaScript は0行です。ただし実際のサイトで別々の URL 間を遷移させる場合は、どのタイミングで次のページの HTML を差し込むかを制御するために、最小限の JavaScript か View Transitions API のような仕組みが必要になることがあります。
ページ遷移 アニメーション css のみで実装するときに一番崩れやすいのはどれですか
06のカード→詳細へのズームです。scale(4) のような大きな拡大率を使うと、拡大された要素の内側の文字や画像が一瞬ぼやけて見えます。この記事のデモでは、拡大が終わる直前に detail という別要素を opacity だけで重ねることで、文字がぼやけたまま止まって見える状態を避けています。 なお、画面ごと替えるのではなく一区画だけが開閉する場合はルーターの出番がなく、高さ以外の値で開閉するCSS アコーディオン アニメーション 9選のやり方が合います。
View Transitions API と transform だけの実装はどちらを使うべきですか
同じ文書内のタブや一覧の切り替えなら、この記事のように transform・opacity・clip-path だけで組む方が、ブラウザを選ばず動くので安全です。異なる URL 間のページ遷移で03のような共有要素の動きを狙うなら、対応ブラウザに限って View Transitions API を使い、非対応ブラウザ向けにはこの記事のデモと同じ transform ベースの代替を用意するのが確実です。









