React アニメーション ライブラリなしで作るCSS9選
Reactで作った画面に動きを付けたいとき最初に思い浮かぶのはreact アニメーション ライブラリの導入ですが、コンポーネントが現れたり消えたりするタイミングさえ押さえれば一行でコピペOKなCSSだけで済むことも多いです。
01 react-css-animation-mount-fade-starting-style
02 react-css-animation-unmount-exit-discrete
03 react-css-animation-list-add-remove
04 react-css-animation-toggle-class-state
05 react-css-animation-key-retrigger
06 react-css-animation-css-modules-setup
07 react-css-animation-view-transition-react-state
08 react-css-animation-suspense-skeleton-swap
09 react-css-animation-route-transition
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 マウント時@starting-styleフェード
- 02 アンマウントexit(allow-discrete)
- 03 リスト追加削除アニメーション
- 04 クラス切替の状態遷移
- 05 keyで再トリガー
- 06 CSS Modules+SCSS設定
- 07 startViewTransition+状態更新
- 08 Suspenseスケルトン切替
- 09 ルート遷移アニメーション
9つは人気順ではなく、Reactコンポーネントが実際にたどる順番で並べています。画面に現れて(01)→閉じると消えて(02)→その間にリストの項目が増減し(03)→状態に応じて見た目が変わり(04)→同じ登場アニメーションをもう一度再生する必要が出て(05)→それを実際にビルドする設定を挟み(06)→画面全体が別の画面に切り替わり(07)→まだ来ていないデータを待ち(08)→最後にページ自体が切り替わる(09)、という流れです。アイデア型(Reactではあまり見ない組み合わせ)は01・02・07番の3つです。コンポーネントへ渡す曲線の値を選ぶ段階ならCSSイージングカーブとスプリングがその一覧で、状態によって数字が変わる画面の例としてはCSS料金表のアニメーションが近い題材です。
01マウント時@starting-styleフェード
どこで使うか
カードコンポーネントがマウントされた瞬間、@starting-styleがDOMに存在しなかった時点の値を定義するため、useEffectで「マウント済み」フラグを別に作らなくてもopacityが0から1へトランジションします。

<main class="stage">
<div class="mf-wrap is-demo">
<div class="mf-card">
<span class="mf-card__dot"></span>
<span class="mf-card__title">New card</span>
<span class="mf-card__sub">mounted just now</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.mf-wrap {
width: min(320px, 90vw);
display: grid;
place-items: center;
}
.mf-card {
width: min(260px, 84vw);
box-sizing: border-box;
padding: $sp-5 $sp-5;
border-radius: $r-card;
background: linear-gradient(160deg, $color, rgba(23, 20, 26, .9));
box-shadow: $shadow-raised;
display: flex;
flex-direction: column;
gap: $sp-1;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", sans-serif;
}
.mf-card__dot {
width: $sp-3;
height: $sp-3;
border-radius: $r-pill;
background: $subject-cream;
margin-bottom: $sp-2;
}
.mf-card__title { font-weight: 700; font-size: 16px; line-height: 1.2; font-family: inherit; }
.mf-card__sub { font-weight: 400; font-size: 12px; line-height: 1.4; font-family: inherit; opacity: .72; }
// 실사용 원리: React 19 는 새 노드가 DOM 에 붙는 순간 @starting-style 의 값에서
// 실제 스타일로 트랜지션한다 (useEffect 로 "mounted" 플래그를 따로 안 만들어도 됨).
// 여기 데모는 그 등장 곡선을 무한 반복으로 보여준다.
@starting-style {
.mf-card { opacity: 0; transform: translateY(10px) scale(.94); }
}
.mf-card {
opacity: 1;
transform: none;
transition: opacity $duration $easing, transform $duration $easing;
}
// 캡처용 루프: 마운트 순간을 2초마다 재생 (demo.py 는 WAAPI 로 이 keyframes 를 직접 스크럽한다)
@keyframes mf-mount {
0% { opacity: 0; transform: translateY(10px) scale(.94); }
22% { opacity: 1; transform: none; }
82% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(10px) scale(.94); }
}
.mf-wrap.is-demo .mf-card { animation: mf-mount $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.mf-card { transition: none; }
.mf-wrap.is-demo .mf-card { animation: none !important; opacity: 1; transform: none; }
}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);
}
.mf-wrap {
width: min(320px, 90vw);
display: grid;
place-items: center;
}
.mf-card {
width: min(260px, 84vw);
box-sizing: border-box;
padding: 20px 20px;
border-radius: 16px;
background: linear-gradient(160deg, #2f6df6, rgba(23, 20, 26, 0.9));
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
gap: 4px;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", sans-serif;
}
.mf-card__dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: #fff7e6;
margin-bottom: 8px;
}
.mf-card__title {
font-weight: 700;
font-size: 16px;
line-height: 1.2;
font-family: inherit;
}
.mf-card__sub {
font-weight: 400;
font-size: 12px;
line-height: 1.4;
font-family: inherit;
opacity: 0.72;
}
@starting-style {
.mf-card {
opacity: 0;
transform: translateY(10px) scale(0.94);
}
}
.mf-card {
opacity: 1;
transform: none;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes mf-mount {
0% {
opacity: 0;
transform: translateY(10px) scale(0.94);
}
22% {
opacity: 1;
transform: none;
}
82% {
opacity: 1;
transform: none;
}
100% {
opacity: 0;
transform: translateY(10px) scale(0.94);
}
}
.mf-wrap.is-demo .mf-card {
animation: mf-mount 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.mf-card {
transition: none;
}
.mf-wrap.is-demo .mf-card {
animation: none !important;
opacity: 1;
transform: none;
}
}import { CSSProperties, useEffect, useState } from "react";
import styles from "./MountFadeStartingStyle.module.scss";
export interface MountFadeStartingStyleProps {
duration?: string;
easing?: string;
color?: string;
}
function Card({ color }: { color: string }) {
// React 19: @starting-style (in the CSS module below) defines what this card looked
// like before it existed in the DOM, so no useEffect("mounted") flag is needed for
// the fade-in — the browser transitions from that starting state automatically.
return (
<div className={styles.card} style={{ background: color }}>
<span className={styles.dot} />
<span className={styles.title}>New card</span>
<span className={styles.sub}>mounted just now</span>
</div>
);
}
export default function MountFadeStartingStyle({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: MountFadeStartingStyleProps) {
const [mounted, setMounted] = useState(true);
useEffect(() => {
const id = setInterval(() => setMounted((m) => !m), 1400);
return () => clearInterval(id);
}, []);
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
return (
<div className={styles.wrap} style={style}>
{mounted ? <Card color={color} /> : null}
</div>
);
}01 · マウント時@starting-styleフェード — 計測 累積面積 19.029% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
押さえておく点
条件付きで新しく描画される通知カードや、リストに追加された直後の項目に向いています。
@starting-style {
.mf-card { opacity: 0; transform: translateY(10px) scale(.94); }
}
.mf-card {
opacity: 1;
transform: none;
transition: opacity $duration $easing, transform $duration $easing;
}
02アンマウントexit(allow-discrete)
見えるもの
閉じるボタンを押しても即座にアンマウントせず、まずclass="is-exiting"を付けてtransition-behavior: allow-discreteでdisplay切り替えを遅らせ、onTransitionEndの中で実際の状態を消します。

<main class="stage">
<div class="ue-wrap is-demo">
<div class="ue-dialog">
<span class="ue-dialog__title">Delete file?</span>
<span class="ue-dialog__btn">Confirm</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.ue-wrap {
width: min(320px, 90vw);
display: grid;
place-items: center;
}
.ue-dialog {
width: min(240px, 82vw);
box-sizing: border-box;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-4;
font-family: -apple-system, "Segoe UI", sans-serif;
}
.ue-dialog__title { font-weight: 700; font-size: 15px; line-height: 1.3; font-family: inherit; color: $color; }
.ue-dialog__btn {
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $stage-orange;
color: $subject-ink; // 주황 면 위 크림 글자는 3.11:1 — 잉크로 5.50:1
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
// 실사용 원리: onClick 에서 바로 state 를 지우지 않고 class="is-exiting" 을 먼저 붙인 뒤
// transition-behavior: allow-discrete 로 display:none 전환을 트랜지션이 끝날 때까지 미룬다.
// 실제 언마운트(state 제거)는 onTransitionEnd 안에서 일어난다.
.ue-dialog {
display: flex;
opacity: 1;
transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing, display $duration allow-discrete;
}
.ue-dialog.is-exiting {
display: none;
opacity: 0;
transform: scale(.92) translateY(6px);
}
// 캡처용 루프: 대화상자가 열려 있다가(60%) 닫힘 트랜지션을 겪고(75%) 다시 열리는 순환
@keyframes ue-exit {
0%, 45% { opacity: 1; transform: scale(1); }
90%, 100% { opacity: 0; transform: scale(.92) translateY(6px); }
}
.ue-wrap.is-demo .ue-dialog { animation: ue-exit $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.ue-dialog { transition: none; }
.ue-wrap.is-demo .ue-dialog { animation: none !important; opacity: 1; transform: none; }
}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);
}
.ue-wrap {
width: min(320px, 90vw);
display: grid;
place-items: center;
}
.ue-dialog {
width: min(240px, 82vw);
box-sizing: border-box;
padding: 20px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
font-family: -apple-system, "Segoe UI", sans-serif;
}
.ue-dialog__title {
font-weight: 700;
font-size: 15px;
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.ue-dialog__btn {
padding: 8px 16px;
border-radius: 12px;
background: #ff4d1f;
color: #17141a;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ue-dialog {
display: flex;
opacity: 1;
transform: scale(1);
transition: opacity 300ms ease-out, transform 300ms ease-out, display 300ms allow-discrete;
}
.ue-dialog.is-exiting {
display: none;
opacity: 0;
transform: scale(0.92) translateY(6px);
}
@keyframes ue-exit {
0%, 45% {
opacity: 1;
transform: scale(1);
}
90%, 100% {
opacity: 0;
transform: scale(0.92) translateY(6px);
}
}
.ue-wrap.is-demo .ue-dialog {
animation: ue-exit 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.ue-dialog {
transition: none;
}
.ue-wrap.is-demo .ue-dialog {
animation: none !important;
opacity: 1;
transform: none;
}
}import { CSSProperties, useState } from "react";
import styles from "./UnmountExitDiscrete.module.scss";
export interface UnmountExitDiscreteProps {
duration?: string;
easing?: string;
}
export default function UnmountExitDiscrete({
duration = "300ms",
easing = "ease-out",
}: UnmountExitDiscreteProps) {
const [open, setOpen] = useState(true);
const [exiting, setExiting] = useState(false);
const close = () => {
// Don't remove the dialog from state right away — add the exit class first.
// transition-behavior: allow-discrete (in the CSS module) keeps display: flex
// alive until the transition actually finishes; onTransitionEnd below does the
// real unmount once the browser reports the animation is done.
setExiting(true);
};
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
if (!open) return null;
return (
<div className={styles.wrap} style={style}>
<div
className={`${styles.dialog} ${exiting ? styles.exiting : ""}`}
onTransitionEnd={() => {
if (exiting) setOpen(false);
}}
>
<span className={styles.title}>Delete file?</span>
<button className={styles.btn} onClick={close}>Confirm</button>
</div>
</div>
);
}02 · アンマウントexit(allow-discrete) — 計測 累積面積 22.772% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どう作るか
モーダル・ダイアログを閉じるときや、トースト消去アニメーションに向いています。
.ue-dialog {
display: flex;
opacity: 1;
transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing, display $duration allow-discrete;
}
.ue-dialog.is-exiting {
display: none;
opacity: 0;
transform: scale(.92) translateY(6px);
}
03リスト追加削除アニメーション
どんな場面か
state配列に項目を追加・削除すると、新しい<li>は01番と同じ@starting-styleで入ってきて、削除される項目は02番のexitクラスのパターンをもう一度使い、アニメーションライブラリなしでもタイミングが揃います。

<main class="stage">
<ul class="la-list is-demo">
<li class="la-item">Wash the car</li>
<li class="la-item">Reply to client email</li>
<li class="la-item la-item--leave">Buy milk</li>
<li class="la-item la-item--enter">Book flight tickets</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-orange);
.la-list {
width: min(240px, 76vw);
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.la-item {
box-sizing: border-box;
padding: $sp-2 $sp-4;
border-radius: $r-sm;
background: $stage-paper;
color: $color;
font: 600 12px/1.3 -apple-system, "Segoe UI", sans-serif;
box-shadow: $shadow-press;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 실사용 원리: 배열에서 항목을 지우면 새 <li> 는 @starting-style 로 들어오고,
// 지워지는 항목은 02번 exit 패턴처럼 클래스가 먼저 붙은 뒤 실제로 제거된다.
// 여기서는 둘 다 transform·opacity 로만 움직여 레이아웃 재계산을 피한다.
@keyframes la-leave {
0%, 40% { opacity: 1; transform: none; }
55% { opacity: 0; transform: translateX(-8px) scale(.97); }
100% { opacity: 0; transform: translateX(-8px) scale(.97); }
}
@keyframes la-enter {
0%, 55% { opacity: 0; transform: translateX(8px) scale(.97); }
85%, 100% { opacity: 1; transform: none; }
}
.la-list.is-demo .la-item--leave { animation: la-leave $dur-loop $easing infinite; }
.la-list.is-demo .la-item--enter { animation: la-enter $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.la-list.is-demo .la-item--leave,
.la-list.is-demo .la-item--enter { animation: none !important; opacity: 1; transform: none; }
}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);
}
.la-list {
width: min(240px, 76vw);
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.la-item {
box-sizing: border-box;
padding: 8px 16px;
border-radius: 8px;
background: #ffffff;
color: #17141a;
font: 600 12px/1.3 -apple-system, "Segoe UI", sans-serif;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes la-leave {
0%, 40% {
opacity: 1;
transform: none;
}
55% {
opacity: 0;
transform: translateX(-8px) scale(0.97);
}
100% {
opacity: 0;
transform: translateX(-8px) scale(0.97);
}
}
@keyframes la-enter {
0%, 55% {
opacity: 0;
transform: translateX(8px) scale(0.97);
}
85%, 100% {
opacity: 1;
transform: none;
}
}
.la-list.is-demo .la-item--leave {
animation: la-leave 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.la-list.is-demo .la-item--enter {
animation: la-enter 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.la-list.is-demo .la-item--leave,
.la-list.is-demo .la-item--enter {
animation: none !important;
opacity: 1;
transform: none;
}
}import { CSSProperties, useState } from "react";
import styles from "./ListAddRemove.module.scss";
interface Task {
id: number;
label: string;
leaving?: boolean;
}
let nextId = 3;
export interface ListAddRemoveProps {
duration?: string;
easing?: string;
}
export default function ListAddRemove({
duration = "300ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
}: ListAddRemoveProps) {
const [items, setItems] = useState<Task[]>([
{ id: 1, label: "Wash the car" },
{ id: 2, label: "Reply to client email" },
]);
const add = () => {
const id = nextId++;
setItems((list) => [...list, { id, label: "Book flight tickets" }]);
};
const startRemove = (id: number) => {
// Mark it leaving first (drives the CSS exit transition, styles.leaving below).
// The item keeps rendering — with opacity heading to 0 — until its own
// onTransitionEnd fires, which is when it actually leaves the array.
setItems((list) => list.map((it) => (it.id === id ? { ...it, leaving: true } : it)));
};
const finishRemove = (id: number) => {
setItems((list) => list.filter((it) => it.id !== id));
};
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
return (
<div className={styles.wrap} style={style}>
<ul className={styles.list}>
{items.map((it) => (
<li
key={it.id}
className={`${styles.item} ${it.leaving ? styles.leaving : styles.entering}`}
onTransitionEnd={() => it.leaving && finishRemove(it.id)}
>
{it.label}
<button className={styles.remove} onClick={() => startRemove(it.id)}>×</button>
</li>
))}
</ul>
<button className={styles.add} onClick={add}>+ Add task</button>
</div>
);
}03 · リスト追加削除アニメーション — 計測 累積面積 14.355% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
TODOリストやカート内の行項目、通知スタックにそのまま使えます。
{items.map((it) => (
<li
key={it.id}
className={`${styles.item} ${it.leaving ? styles.leaving : styles.entering}`}
onTransitionEnd={() => it.leaving && finishRemove(it.id)}
>
{it.label}
<button className={styles.remove} onClick={() => startRemove(it.id)}>×</button>
</li>
))}
04クラス切替の状態遷移
何が起きるか
1つのboolean状態がそのまま1つのクラス名にマッピングされ、その間の動きはすべてCSSトランジションが担当します。

<main class="stage">
<div class="tg-panel is-demo">
<div class="tg-panel__head">
<span class="tg-panel__label">Shipping details</span>
<span class="tg-panel__chevron">▾</span>
</div>
<div class="tg-panel__body">Arrives in 2–3 business days via standard mail.</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue-light; // 잉크 면 위 파란 글자 전용 토큰 — $subject-blue 는 4.03:1
@include stage($stage-paper);
.tg-panel {
width: min(280px, 88vw);
box-sizing: border-box;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
font-family: -apple-system, "Segoe UI", sans-serif;
}
.tg-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-4 $sp-4;
}
.tg-panel__label { font-weight: 600; font-size: 14px; line-height: 1; font-family: inherit; }
.tg-panel__chevron {
display: inline-block;
color: $color;
transition: transform $duration $easing;
}
.tg-panel__body {
padding: 0 $sp-4 $sp-4;
font-weight: 400;
font-size: 12px;
line-height: 1.5;
font-family: inherit;
opacity: .85;
transform-origin: top; // 규칙8: 패널 위변 scaleY 펼침
transition: opacity $duration $easing, transform $duration $easing;
}
// 실사용 원리: isOpen 상태 하나가 className="is-open" 하나로 매핑되고, 사이 움직임은
// 전부 CSS transition 이 맡는다 (React state → class, class → motion).
.tg-panel:not(.is-open) .tg-panel__chevron { transform: rotate(-90deg); }
.tg-panel:not(.is-open) .tg-panel__body { opacity: 0; transform: scaleY(0); }
// 캡처용 루프: 닫힘 → 열림 → 닫힘을 2초에 걸쳐 반복
@keyframes tg-chevron { 0%, 15% { transform: rotate(-90deg); } 45%, 70% { transform: rotate(0deg); } 100% { transform: rotate(-90deg); } }
@keyframes tg-body { 0%, 15% { opacity: 0; transform: scaleY(0); } 45%, 70% { opacity: .85; transform: scaleY(1); } 100% { opacity: 0; transform: scaleY(0); } }
.tg-panel.is-demo .tg-panel__chevron { animation: tg-chevron $dur-loop $easing infinite; }
.tg-panel.is-demo .tg-panel__body { animation: tg-body $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.tg-panel__chevron, .tg-panel__body { transition: none; }
.tg-panel.is-demo .tg-panel__chevron,
.tg-panel.is-demo .tg-panel__body { animation: none !important; opacity: .85; transform: none; }
}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);
}
.tg-panel {
width: min(280px, 88vw);
box-sizing: border-box;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
font-family: -apple-system, "Segoe UI", sans-serif;
}
.tg-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px;
}
.tg-panel__label {
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.tg-panel__chevron {
display: inline-block;
color: #5b8cf8;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg-panel__body {
padding: 0 16px 16px;
font-weight: 400;
font-size: 12px;
line-height: 1.5;
font-family: inherit;
opacity: 0.85;
transform-origin: top;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg-panel:not(.is-open) .tg-panel__chevron {
transform: rotate(-90deg);
}
.tg-panel:not(.is-open) .tg-panel__body {
opacity: 0;
transform: scaleY(0);
}
@keyframes tg-chevron {
0%, 15% {
transform: rotate(-90deg);
}
45%, 70% {
transform: rotate(0deg);
}
100% {
transform: rotate(-90deg);
}
}
@keyframes tg-body {
0%, 15% {
opacity: 0;
transform: scaleY(0);
}
45%, 70% {
opacity: 0.85;
transform: scaleY(1);
}
100% {
opacity: 0;
transform: scaleY(0);
}
}
.tg-panel.is-demo .tg-panel__chevron {
animation: tg-chevron 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tg-panel.is-demo .tg-panel__body {
animation: tg-body 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.tg-panel__chevron, .tg-panel__body {
transition: none;
}
.tg-panel.is-demo .tg-panel__chevron,
.tg-panel.is-demo .tg-panel__body {
animation: none !important;
opacity: 0.85;
transform: none;
}
}import { CSSProperties, useState } from "react";
import styles from "./ToggleClassState.module.scss";
export interface ToggleClassStateProps {
duration?: string;
easing?: string;
}
export default function ToggleClassState({
duration = "300ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
}: ToggleClassStateProps) {
// The idiomatic React pattern: one boolean state maps straight onto one className.
// Everything visual in between is CSS's job, not this component's.
const [open, setOpen] = useState(false);
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
return (
<div className={`${styles.panel} ${open ? styles.open : ""}`} style={style}>
<button className={styles.head} onClick={() => setOpen((v) => !v)}>
<span className={styles.label}>Shipping details</span>
<span className={styles.chevron}>▾</span>
</button>
<div className={styles.body}>Arrives in 2–3 business days via standard mail.</div>
</div>
);
}04 · クラス切替の状態遷移 — 計測 累積面積 1.805% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの要点
「状態がクラスを決め、動きはCSSが決める」というReactで最も一般的なパターンで、開閉パネルやアクティブ状態のナビ項目にそのまま使えます。
.tg-panel:not(.is-open) .tg-panel__chevron { transform: rotate(-90deg); }
.tg-panel:not(.is-open) .tg-panel__body { opacity: 0; transform: scaleY(0); }
.tg-panel__body {
transform-origin: top;
transition: opacity $duration $easing, transform $duration $easing;
}
05keyで再トリガー
画面で起きること
key propの値を変えるとReactはそのコンポーネントを丸ごとアンマウントし、新しいインスタンスとして再マウントするため、マウント時に一度だけ再生される登場アニメーションを好きなタイミングで再生できます。

<main class="stage">
<div class="kr-wrap">
<span class="kr-label">Score</span>
<span class="kr-badge is-demo">+120</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 640ms;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.kr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", sans-serif;
color: $subject-cream;
}
.kr-label { font-weight: 500; font-size: 13px; line-height: 1; font-family: inherit; opacity: .7; letter-spacing: .04em; text-transform: uppercase; }
.kr-badge {
padding: $sp-3 $sp-6;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-weight: 800;
font-size: 22px;
line-height: 1;
font-family: inherit;
box-shadow: $shadow-raised;
}
// 실사용 원리: key prop 을 바꾸면 리액트가 이 배지를 통째로 언마운트하고 새 인스턴스로
// 다시 마운트한다 — "마운트 때 한 번만" 재생되는 팝 애니메이션이 매번 처음부터 다시 돈다.
// 캡처용 루프: 2초 중 앞부분에 팝 → 나머지는 정지 상태로 유지 (재트리거를 기다리는 느낌)
@keyframes kr-pop {
0% { opacity: 0; transform: scale(.4); }
28% { opacity: 1; transform: scale(1.14); }
38% { opacity: 1; transform: scale(.96); }
48%, 100% { opacity: 1; transform: scale(1); }
}
.kr-badge.is-demo { animation: kr-pop $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.kr-badge.is-demo { animation: none !important; opacity: 1; transform: none; }
}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);
}
.kr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
}
.kr-label {
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
opacity: 0.7;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.kr-badge {
padding: 12px 24px;
border-radius: 999px;
background: #2f6df6;
color: #fff7e6;
font-weight: 800;
font-size: 22px;
line-height: 1;
font-family: inherit;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
@keyframes kr-pop {
0% {
opacity: 0;
transform: scale(0.4);
}
28% {
opacity: 1;
transform: scale(1.14);
}
38% {
opacity: 1;
transform: scale(0.96);
}
48%, 100% {
opacity: 1;
transform: scale(1);
}
}
.kr-badge.is-demo {
animation: kr-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.kr-badge.is-demo {
animation: none !important;
opacity: 1;
transform: none;
}
}import { CSSProperties, useState } from "react";
import styles from "./KeyRetrigger.module.scss";
function Badge({ value }: { value: number; key?: number }) {
// The key on this element (set by the caller below) forces React to unmount and
// remount it on every change, so the pop-in @keyframes — which only plays once per
// mount — replays from scratch each time the score bumps.
return <span className={styles.badge}>+{value}</span>;
}
export interface KeyRetriggerProps {
duration?: string;
easing?: string;
}
export default function KeyRetrigger({
duration = "640ms",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
}: KeyRetriggerProps) {
const [score, setScore] = useState(120);
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
return (
<div className={styles.wrap} style={style}>
<span className={styles.label}>Score</span>
<Badge key={score} value={score} />
<button className={styles.btn} onClick={() => setScore((s) => s + 10)}>+10</button>
</div>
);
}05 · keyで再トリガー — 計測 累積面積 4.161% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
動く仕組み
確認エラーの揺れをもう一度見せたいときや、スコアが変わるたびにお祝いエフェクトを出し直したいときに使います。
function Badge({ value }: { value: number; key?: number }) {
// keyが変わるたびにReactはこの要素をアンマウント・再マウントし、
// マウント時1回限りのpopキーフレームが毎回最初から再生される。
return <span className={styles.badge}>+{value}</span>;
}
// 呼び出し側: <Badge key={score} value={score} />
06CSS Modules+SCSS設定
見え方
この記事のreact/フォルダ9つ全部が頼っている実際のビルド設定を示す項目で、*.module.scssファイルがstyles.fooのようなローカルスコープのクラス名にコンパイルされ、グローバル衝突なしにコンポーネントごとにスタイルが閉じ込められます。

<main class="stage">
<div class="cm-term is-demo">
<div class="cm-term__bar"><span></span><span></span><span></span></div>
<div class="cm-term__line cm-term__line--1">Card.module.scss</div>
<div class="cm-term__line cm-term__line--2">→ styles.card_a3f9k1</div>
<div class="cm-term__line cm-term__line--3">→ styles.title_p02qz8</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.cm-term {
width: min(300px, 90vw);
box-sizing: border-box;
padding: $sp-4;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
font: 500 12px/1.6 "SFMono-Regular", Consolas, monospace;
color: $subject-cream;
}
.cm-term__bar {
display: flex;
gap: $sp-1;
margin-bottom: $sp-3;
span {
width: $sp-2; height: $sp-2; border-radius: $r-pill;
background: rgba(255, 255, 255, .25);
}
}
.cm-term__line {
opacity: 0;
transform: translateY(4px);
}
.cm-term__line--2 { color: $color; }
.cm-term__line--3 { color: $color; }
// 실사용 원리: *.module.scss 는 빌드 시 클래스명을 styles.foo → foo_a3f9k1 같은
// 지역 스코프 해시로 바꿔 컴파일한다. 여기서는 그 컴파일 로그가 한 줄씩 찍히는 걸 보여준다.
@keyframes cm-line {
0%, 8% { opacity: 0; transform: translateY(4px); }
20%, 92% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(4px); }
}
.cm-term.is-demo .cm-term__line--1 { animation: cm-line $dur-loop $easing infinite; animation-delay: 0ms; }
.cm-term.is-demo .cm-term__line--2 { animation: cm-line $dur-loop $easing infinite; animation-delay: 220ms; }
.cm-term.is-demo .cm-term__line--3 { animation: cm-line $dur-loop $easing infinite; animation-delay: 440ms; }
@media (prefers-reduced-motion: reduce) {
.cm-term.is-demo .cm-term__line { animation: none !important; opacity: 1; transform: none; }
}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);
}
.cm-term {
width: min(300px, 90vw);
box-sizing: border-box;
padding: 16px;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font: 500 12px/1.6 "SFMono-Regular", Consolas, monospace;
color: #fff7e6;
}
.cm-term__bar {
display: flex;
gap: 4px;
margin-bottom: 12px;
}
.cm-term__bar span {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
}
.cm-term__line {
opacity: 0;
transform: translateY(4px);
}
.cm-term__line--2 {
color: #2f6df6;
}
.cm-term__line--3 {
color: #2f6df6;
}
@keyframes cm-line {
0%, 8% {
opacity: 0;
transform: translateY(4px);
}
20%, 92% {
opacity: 1;
transform: none;
}
100% {
opacity: 0;
transform: translateY(4px);
}
}
.cm-term.is-demo .cm-term__line--1 {
animation: cm-line 2s ease-out infinite;
animation-delay: 0ms;
}
.cm-term.is-demo .cm-term__line--2 {
animation: cm-line 2s ease-out infinite;
animation-delay: 220ms;
}
.cm-term.is-demo .cm-term__line--3 {
animation: cm-line 2s ease-out infinite;
animation-delay: 440ms;
}
@media (prefers-reduced-motion: reduce) {
.cm-term.is-demo .cm-term__line {
animation: none !important;
opacity: 1;
transform: none;
}
}import { CSSProperties } from "react";
import styles from "./CssModulesSetup.module.scss";
export interface CssModulesSetupProps {
duration?: string;
easing?: string;
}
// This item is meta: styles.card and styles.title below aren't hardcoded strings —
// they're exactly what CSS Modules generated from the classnames in
// CssModulesSetup.module.scss at build time (e.g. "card_a3f9k1"). Rendering them is
// literally what this whole post's react/ folder relies on.
export default function CssModulesSetup({
duration = "220ms",
easing = "ease-out",
}: CssModulesSetupProps) {
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
return (
<div className={styles.term} style={style}>
<div className={styles.bar}><span /><span /><span /></div>
<div className={styles.line}>Card.module.scss</div>
<div className={styles.accent}>styles.card → "{styles.card}"</div>
<div className={styles.accent}>styles.title → "{styles.title}"</div>
</div>
);
}06 · CSS Modules+SCSS設定 — 計測 累積面積 1.792% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
組み立て方
コンポーネントスコープのSCSSを使いたいReactプロジェクト全般に当てはまります。
// styles.card・styles.titleはハードコードした文字列ではなく、
// CssModulesSetup.module.scssのクラス名がビルド時に実際にコンパイルされた値。
<div className={styles.term}>
<div className={styles.accent}>styles.card → "{styles.card}"</div>
<div className={styles.accent}>styles.title → "{styles.title}"</div>
</div>
07startViewTransition+状態更新
react 画面遷移 アニメーション をどこで使うか
document.startViewTransition()がReact状態更新を包むと、ブラウザが変更前後のDOMスナップショットを自動的にクロスフェードするため、変更した要素ごとに手動でアニメーションをかける必要がありません。

<main class="stage">
<div class="vt-frame is-demo">
<div class="vt-layer vt-layer--a">
<span class="vt-chip">List view</span>
</div>
<div class="vt-layer vt-layer--b">
<span class="vt-chip">Grid view</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.vt-frame {
position: relative;
width: min(260px, 84vw);
aspect-ratio: 260 / 170;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vt-layer {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $subject-cream;
}
.vt-layer--a { background: linear-gradient(160deg, $subject-ink, rgba(23, 20, 26, .8)); }
.vt-layer--b { background: linear-gradient(160deg, $color, rgba(23, 20, 26, .85)); }
.vt-chip {
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: rgba(255, 255, 255, .16);
font: 700 13px/1 -apple-system, "Segoe UI", sans-serif;
}
// 실사용 원리: document.startViewTransition() 이 flushSync 로 감싼 상태 갱신을 가로채,
// 바뀌기 전·후 DOM 스냅샷을 브라우저가 자동으로 크로스페이드한다.
.vt-layer--b { opacity: 0; }
// 캡처용 루프: list view ↔ grid view 를 2초 주기로 크로스페이드
@keyframes vt-cross-out { 0%, 20% { opacity: 1; } 80%, 100% { opacity: 0; } }
@keyframes vt-cross-in { 0%, 20% { opacity: 0; } 80%, 100% { opacity: 1; } }
.vt-frame.is-demo .vt-layer--a { animation: vt-cross-out $dur-loop $easing infinite; }
.vt-frame.is-demo .vt-layer--b { animation: vt-cross-in $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.vt-frame.is-demo .vt-layer--a,
.vt-frame.is-demo .vt-layer--b { animation: none !important; }
.vt-layer--a { opacity: 1; }
.vt-layer--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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vt-frame {
position: relative;
width: min(260px, 84vw);
aspect-ratio: 260/170;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vt-layer {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff7e6;
}
.vt-layer--a {
background: linear-gradient(160deg, #17141a, rgba(23, 20, 26, 0.8));
}
.vt-layer--b {
background: linear-gradient(160deg, #2f6df6, rgba(23, 20, 26, 0.85));
}
.vt-chip {
padding: 8px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
font: 700 13px/1 -apple-system, "Segoe UI", sans-serif;
}
.vt-layer--b {
opacity: 0;
}
@keyframes vt-cross-out {
0%, 20% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
@keyframes vt-cross-in {
0%, 20% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
.vt-frame.is-demo .vt-layer--a {
animation: vt-cross-out 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vt-frame.is-demo .vt-layer--b {
animation: vt-cross-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.vt-frame.is-demo .vt-layer--a,
.vt-frame.is-demo .vt-layer--b {
animation: none !important;
}
.vt-layer--a {
opacity: 1;
}
.vt-layer--b {
opacity: 0;
}
}import { CSSProperties, useState } from "react";
import { flushSync } from "react-dom";
import styles from "./ViewTransitionReactState.module.scss";
type DocWithViewTransition = Document & {
startViewTransition?: (callback: () => void) => void;
};
export interface ViewTransitionReactStateProps {
duration?: string;
easing?: string;
}
export default function ViewTransitionReactState({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
}: ViewTransitionReactStateProps) {
const [mode, setMode] = useState<"list" | "grid">("list");
const toggle = () => {
const doc = document as DocWithViewTransition;
const next = mode === "list" ? "grid" : "list";
if (doc.startViewTransition) {
// startViewTransition needs the "after" DOM already painted by the time its
// callback returns — flushSync forces that React commit synchronously instead
// of letting React 18's automatic batching defer it past the snapshot.
doc.startViewTransition(() => flushSync(() => setMode(next)));
} else {
setMode(next);
}
};
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
return (
<button className={styles.frame} style={style} onClick={toggle}>
<span className={styles.chip}>{mode === "list" ? "List view" : "Grid view"}</span>
</button>
);
}07 · startViewTransition+状態更新 — 計測 累積面積 30.558% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
なぜこう書くか
ただしコールバックの中でsetStateだけを呼ぶとReact 18の自動バッチングがコミットを遅らせてしまうため、flushSyncでその更新を同期的に強制しないとスナップショットのタイミングが合いません。
const toggle = () => {
const doc = document as DocWithViewTransition;
const next = mode === "list" ? "grid" : "list";
if (doc.startViewTransition) {
doc.startViewTransition(() => flushSync(() => setMode(next)));
} else {
setMode(next);
}
};
08Suspenseスケルトン切替
どの画面か
React.SuspenseのfallbackスロットにCSSシマースケルトンを置いておくと、遅延読み込みされたコンポーネントの準備ができた時にfallbackが抜け、実際のコンテンツが同じ場所でフェードして入ってくるため画面がガクッとなりません。

<main class="stage">
<div class="sk-card is-demo">
<div class="sk-skeleton">
<span class="sk-bar sk-bar--title"></span>
<span class="sk-bar sk-bar--line"></span>
<span class="sk-bar sk-bar--line short"></span>
</div>
<div class="sk-content">
<span class="sk-content__title">Widget loaded</span>
<span class="sk-content__line">Lazy-loaded with React.lazy + Suspense</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.sk-card {
position: relative;
width: min(260px, 84vw);
height: 140px;
box-sizing: border-box;
padding: $sp-5;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
overflow: hidden;
}
.sk-skeleton, .sk-content {
position: absolute;
inset: $sp-5;
display: flex;
flex-direction: column;
gap: $sp-3;
}
.sk-bar {
height: $sp-3;
border-radius: $r-xs;
background: linear-gradient(90deg, rgba(255, 255, 255, .1) 25%, rgba(255, 255, 255, .24) 50%, rgba(255, 255, 255, .1) 75%);
background-size: 200% 100%;
animation: sk-shimmer 1.1s linear infinite;
}
.sk-bar--title { width: 60%; height: $sp-4; }
.sk-bar--line.short { width: 40%; }
@keyframes sk-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.sk-content { justify-content: center; color: $subject-cream; font-family: -apple-system, "Segoe UI", sans-serif; }
.sk-content__title { font-weight: 700; font-size: 15px; line-height: 1.3; font-family: inherit; }
.sk-content__line { font-weight: 400; font-size: 12px; line-height: 1.5; font-family: inherit; color: $color; }
// 실사용 원리: React.Suspense 의 fallback 자리를 CSS 셰머로 채워두고, 지연 로드된
// 컴포넌트가 준비되면 fallback 은 빠지고 실제 콘텐츠가 같은 자리에서 페이드로 들어온다.
.sk-content { opacity: 0; }
// 캡처용 루프: 스켈레톤(0~55%) → 콘텐츠(65~100%) 크로스페이드
@keyframes sk-out { 0%, 30% { opacity: 1; } 75%, 100% { opacity: 0; } }
@keyframes sk-in { 0%, 30% { opacity: 0; } 75%, 100% { opacity: 1; } }
.sk-card.is-demo .sk-skeleton { animation: sk-out $dur-loop $easing infinite; }
.sk-card.is-demo .sk-content { animation: sk-in $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.sk-bar { animation: none !important; }
.sk-card.is-demo .sk-skeleton,
.sk-card.is-demo .sk-content { animation: none !important; }
.sk-skeleton { opacity: 0; }
.sk-content { opacity: 1; }
}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);
}
.sk-card {
position: relative;
width: min(260px, 84vw);
height: 140px;
box-sizing: border-box;
padding: 20px;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.sk-skeleton, .sk-content {
position: absolute;
inset: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sk-bar {
height: 12px;
border-radius: 4px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.24) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
animation: sk-shimmer 1.1s linear infinite;
}
.sk-bar--title {
width: 60%;
height: 16px;
}
.sk-bar--line.short {
width: 40%;
}
@keyframes sk-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.sk-content {
justify-content: center;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", sans-serif;
}
.sk-content__title {
font-weight: 700;
font-size: 15px;
line-height: 1.3;
font-family: inherit;
}
.sk-content__line {
font-weight: 400;
font-size: 12px;
line-height: 1.5;
font-family: inherit;
color: #2f6df6;
}
.sk-content {
opacity: 0;
}
@keyframes sk-out {
0%, 30% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
@keyframes sk-in {
0%, 30% {
opacity: 0;
}
75%, 100% {
opacity: 1;
}
}
.sk-card.is-demo .sk-skeleton {
animation: sk-out 2s ease-out infinite;
}
.sk-card.is-demo .sk-content {
animation: sk-in 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.sk-bar {
animation: none !important;
}
.sk-card.is-demo .sk-skeleton,
.sk-card.is-demo .sk-content {
animation: none !important;
}
.sk-skeleton {
opacity: 0;
}
.sk-content {
opacity: 1;
}
}import React, { CSSProperties } from "react";
import styles from "./SuspenseSkeletonSwap.module.scss";
function Skeleton() {
return (
<div className={styles.skeleton}>
<span className={`${styles.bar} ${styles.title}`} />
<span className={styles.bar} />
<span className={`${styles.bar} ${styles.short}`} />
</div>
);
}
// React.lazy simulates a code-split widget — in a real app this import() would be a
// separate chunk fetched over the network, which is exactly what Suspense's fallback
// (the shimmer skeleton above) is there to cover while it loads.
const Widget = React.lazy(
() =>
new Promise<{ default: () => any }>((resolve) => {
setTimeout(() => {
resolve({
default: () => (
<div className={styles.content}>
<span className={styles.contentTitle}>Widget loaded</span>
<span className={styles.contentLine}>Lazy-loaded with React.lazy + Suspense</span>
</div>
),
});
}, 900);
})
);
export interface SuspenseSkeletonSwapProps {
duration?: string;
easing?: string;
}
export default function SuspenseSkeletonSwap({
duration = "600ms",
easing = "ease-out",
}: SuspenseSkeletonSwapProps) {
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
return (
<div className={styles.card} style={style}>
<React.Suspense fallback={<Skeleton />}>
<Widget />
</React.Suspense>
</div>
);
}08 · Suspenseスケルトン切替 — 計測 累積面積 4.592% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
実装の核
コード分割されたルートのコンテンツや、遅延読み込みされるダッシュボードウィジェットに向いています。
.sk-bar {
background: linear-gradient(90deg, rgba(255,255,255,.1) 25%, rgba(255,255,255,.24) 50%, rgba(255,255,255,.1) 75%);
background-size: 200% 100%;
animation: sk-shimmer 1.1s linear infinite;
}
@keyframes sk-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
09ルート遷移アニメーション
何が動くか
URLパラメータをそのままコンポーネントのkeyとして使うか、ルート切り替えをstartViewTransitionで包むと、出ていくページと入ってくるページが瞬間切り替えの代わりにCSSトランジションでつながります。

<main class="stage">
<div class="rt-frame is-demo">
<div class="rt-page rt-page--out">
<span class="rt-page__label">/ home</span>
</div>
<div class="rt-page rt-page--in">
<span class="rt-page__label">/ pricing</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.rt-frame {
position: relative;
width: min(280px, 88vw);
aspect-ratio: 280 / 160;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.rt-page {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.rt-page--out { background: linear-gradient(160deg, rgba(255, 255, 255, .12), rgba(255, 255, 255, .02)); }
.rt-page--in { background: linear-gradient(160deg, $color, rgba(23, 20, 26, .9)); }
.rt-page__label {
padding: $sp-2 $sp-5;
border-radius: $r-pill;
background: rgba(0, 0, 0, .28);
color: $subject-cream;
font: 700 13px/1 "SFMono-Regular", Consolas, monospace;
}
// 실사용 원리: URL 파라미터를 컴포넌트의 key 로 쓰거나 라우트 전환을 startViewTransition 으로
// 감싸면, 나가는 페이지와 들어오는 페이지가 순간 전환 대신 CSS 트랜지션으로 이어진다.
.rt-page--in { transform: translateX(100%); }
// 캡처용 루프: home 이 왼쪽으로 나가고 pricing 이 오른쪽에서 들어오는 전환을 2초 주기로 반복
@keyframes rt-out { 0%, 15% { transform: none; } 75%, 100% { transform: translateX(-100%); } }
@keyframes rt-in { 0%, 15% { transform: translateX(100%); } 75%, 100% { transform: none; } }
.rt-frame.is-demo .rt-page--out { animation: rt-out $dur-loop $easing infinite; }
.rt-frame.is-demo .rt-page--in { animation: rt-in $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.rt-frame.is-demo .rt-page--out,
.rt-frame.is-demo .rt-page--in { animation: none !important; }
.rt-page--out { transform: none; }
.rt-page--in { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rt-frame {
position: relative;
width: min(280px, 88vw);
aspect-ratio: 280/160;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rt-page {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.rt-page--out {
background: linear-gradient(160deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02));
}
.rt-page--in {
background: linear-gradient(160deg, #2f6df6, rgba(23, 20, 26, 0.9));
}
.rt-page__label {
padding: 8px 20px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
color: #fff7e6;
font: 700 13px/1 "SFMono-Regular", Consolas, monospace;
}
.rt-page--in {
transform: translateX(100%);
}
@keyframes rt-out {
0%, 15% {
transform: none;
}
75%, 100% {
transform: translateX(-100%);
}
}
@keyframes rt-in {
0%, 15% {
transform: translateX(100%);
}
75%, 100% {
transform: none;
}
}
.rt-frame.is-demo .rt-page--out {
animation: rt-out 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.rt-frame.is-demo .rt-page--in {
animation: rt-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.rt-frame.is-demo .rt-page--out,
.rt-frame.is-demo .rt-page--in {
animation: none !important;
}
.rt-page--out {
transform: none;
}
.rt-page--in {
transform: translateX(100%);
}
}import { CSSProperties, useState } from "react";
import styles from "./RouteTransition.module.scss";
const ROUTES = ["/ home", "/ pricing"];
function Page({ label }: { label: string; key?: string }) {
// key={label} on the caller side makes each route its own component instance —
// switching the key unmounts the outgoing page and mounts the incoming one,
// instead of patching one page's content in place.
return (
<div className={styles.page}>
<span className={styles.label}>{label}</span>
</div>
);
}
export interface RouteTransitionProps {
duration?: string;
easing?: string;
}
export default function RouteTransition({
duration = "300ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
}: RouteTransitionProps) {
const [route, setRoute] = useState(0);
const navigate = () => {
const next = (route + 1) % ROUTES.length;
if (typeof document !== "undefined" && "startViewTransition" in document) {
(document as any).startViewTransition(() => setRoute(next));
} else {
setRoute(next);
}
};
const style: CSSProperties & { "--duration"?: string; "--easing"?: string } = {
"--duration": duration,
"--easing": easing,
};
return (
<button className={styles.frame} style={style} onClick={navigate}>
<Page key={ROUTES[route]} label={ROUTES[route]} />
</button>
);
}09 · ルート遷移アニメーション — 計測 累積面積 30.983% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
何が肝心か
SPAのルート遷移や、同じレイアウトを共有するタブ風のページに向いています。
const navigate = () => {
const next = (route + 1) % ROUTES.length;
if (typeof document !== "undefined" && "startViewTransition" in document) {
(document as any).startViewTransition(() => setRoute(next));
} else {
setRoute(next);
}
};
// <Page key={ROUTES[route]} label={ROUTES[route]} />
どこで壊れるか — 9つを作って踏んだ落とし穴
何が問題か
react アニメーション フェード イン から順に9つを作っていて一番よく踏んだ失敗は08番(Suspenseスケルトン)でした。最初の実装ではスケルトンとコンテンツを両方position: absoluteで重ねただけで、外側のカードに高さを明示していなかったため、カードそのものが潰れた40pxほどの帯としてレンダリングされてしまいました——絶対位置の子要素は親の高さ計算に入らないという、CSSを触ったことがある人なら知っている落とし穴ですが、Reactコンポーネントの中に隠れていると気づきにくいものです。実測では累積面積が0.67%から4.96%まで回復し、heightを固定してから正常になりました。
どう避けるか
再レンダリングのたびにアニメーションが最初へ戻る問題は、背景まで流れ続けるオーロラのメッシュグラデーションLPパーツのように途切れるとすぐ分かる画面でより痛みます。
もう一つは03番(リスト追加削除)で、幅を320pxまで縮めるとテキストが2行に折り返り、リスト全体の高さがビューポートをはみ出す問題です。9つ入った圧縮ファイルを開くときのパスワードはndwa6jafで、そのまま貼り付ける前に各項目へwhite-space: nowrapとtext-overflow: ellipsisを当てて1行に固定しておくと同じ問題を避けられます。keyを扱う05・09番にもよくある落とし穴があり、keyの値を変えずに中身だけ変えると同じインスタンスが保たれたままになり、マウントアニメーションが一度も再生されません。
アクセシビリティ
reduced-motion で残るもの
9つともprefers-reduced-motion: reduceではループアニメーションを止め、最終状態(マウントなら表示、折りたたみなら閉じた状態、ルートなら到達後の画面)にそのまま固定します。01・02番のようにトランジション自体が情報のすべてではない項目はアニメーションがなくても機能がそのまま残り、05番(keyの再トリガー)のように「もう一度知らせる」役割の項目はreduced-motionでも数値自体は即座に切り替わるため情報は失われません。
そのほかに気をつけること
zipの中のvanilla・reactコードにも同じメディアクエリがそのまま入っているので、手を加えなくてもアクセシビリティの既定値がついてきます。他に配布しているCSS作例はカテゴリー一覧に、状態をクラス1つで切り替える別の例はトグルスイッチ9選にまとめてあります。
FAQ
react アニメーション サンプル を動かすのにライブラリは必須ですか?
ほとんどのマウント・アンマウント・状態遷移は、この記事の9つのようにCSSとReactのフックだけで足ります。タイムラインを細かく調整したい場合や、ドラッグジェスチャーのような物理シミュレーションが必要な場合だけ、ライブラリの方が有利です。
@starting-styleはどのブラウザで使えますか?
最新のChrome・Edge・Operaは対応していますが、SafariとFirefoxは対応状況がバージョンによって分かれます。対応していないブラウザではトランジションなしでいきなり表示されるだけでレイアウトは壊れないため、プログレッシブエンハンスメントとして使っても安全です。詳しい対応状況はMDNの@starting-styleのドキュメントで確認できます。
key propを変えるとパフォーマンスに問題はありませんか?
keyが変わるたびにコンポーネントが丸ごと新しく作り直されるため、頻繁に変わるリスト全体に毎回新しいkeyを振ると負荷が大きくなります。05番のように再生トリガーが必要な小さな要素1つだけに絞って使うのがおすすめです。keyで状態を初期化する仕組みはReact公式ドキュメントに、クラス1つで状態を切り替える別の例は保存・コピーのような状態遷移9選にもまとめてあります。ルートが切り替わる間に何を見せるかが問題なら、ページ遷移のローディングがその空白を埋めます。









