9 CSS Page Transitions — No Library, Pure CSS
css page transitions swap two views using only transform, opacity, and clip-path — no plugin, no library.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Default cross-fade
- 02 Left/right slide
- 03 Shared element hero morph
- 04 Circle reveal from click point
- 05 App-style stack push
- 06 Card to detail zoom
- 07 Tab content cross-fade
- 08 Animated list reorder
- 09 Theme dark-mode wipe
The order below follows how much of the screen the transition actually touches. 01 and 02 swap the whole page with one property each — opacity, then transform. 03 through 06 carry one element's identity across the swap, from a thumbnail growing into a hero image to a card filling the screen. 07 and 08 stay inside a single page, reordering or refreshing a region without ever leaving it. 09 closes the set with a page-wide mode switch. Every panel below runs its own two-second demo loop; the untrimmed files, including the vanilla and React versions, are in the zip. For a change that covers one layer without leaving the page at all, the CSS modal animation patterns work at that smaller size, and when the thing that triggers a swap like 03 through 06 is the top menu, the icon half of it is in the CSS hamburger menu animations.
01Default cross-fade
The motion
The old page's opacity drops to zero while the new page's opacity rises to one, both painted in the same spot so the swap reads as one image dissolving into another.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">ARTICLE</p>
<h2 class="page__title">Previous post</h2>
<p class="page__body">Build variants with 3 CSS variables</p>
</section>
<section class="page page--b">
<p class="page__tag">ARTICLE</p>
<h2 class="page__title">Next post</h2>
<p class="page__body">Transitions have a clock too</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 · Default cross-fade — Measured area 31.122% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a css page transition animation fits
It's the transition to reach for first — blog posts, general navigation, anywhere a jump-cut would feel abrupt but a slide would feel like too much.
.page--a { animation: cross-a $dur-base $ease-out; }
.page--b { animation: cross-b $dur-base $ease-out; opacity: 0; }
@keyframes cross-a {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes cross-b {
0% { opacity: 0; }
100% { opacity: 1; }
}
02Left/right slide
What it does
The incoming page pushes in from the right on translateX while the outgoing page is shoved out to the left, so the whole screen reads as one strip of content sliding past a fixed viewport.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">STEP 1</p>
<h2 class="page__title">Shipping address</h2>
<p class="page__body">Enter your address</p>
</section>
<section class="page page--b">
<p class="page__tag">STEP 2</p>
<h2 class="page__title">Payment details</h2>
<p class="page__body">Enter your card number</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 · Left/right slide — Measured area 31.126% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
A css page transition slide left suits step forms and any mobile flow where back and forward need an obvious direction, since reversing the transform reverses the direction for free.
.page--a { animation: slide-a $dur-quick $ease-spring; }
.page--b { animation: slide-b $dur-quick $ease-spring; transform: translateX(100%); }
@keyframes slide-a {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
@keyframes slide-b {
0% { transform: translateX(100%); }
100% { transform: translateX(0); }
}
03Shared element hero morph
What you see
One thumbnail in a list grows in place until it fills the frame as the next page's hero image, while a caption fades in only once the shape has finished expanding.

<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">Photo detail</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 · Shared element hero morph — Measured area 17.511% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It's built for product and portfolio detail pages, where jumping straight to a static hero loses the connection between the list item you picked and the page you landed on.
.hero {
position: absolute; left: 5vw; top: 46px; width: 30%; height: 44px;
transform-origin: top left;
animation: hero-grow $dur-loop $ease-spring infinite;
}
@keyframes hero-grow {
0%, 40% { width: 30%; height: 44px; top: 46px; }
70%, 90% { width: 88%; height: 108px; top: 12px; }
100% { width: 30%; height: 44px; top: 46px; }
}
04Circle reveal from click point
The motion
A circle expands outward from a fixed point using clip-path, uncovering the next page from that origin instead of wiping the whole screen at once.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">SETTINGS</p>
<h2 class="page__title">Light mode</h2>
<p class="page__body">Bright on the eyes</p>
</section>
<section class="page page--b page--circle">
<p class="page__tag">SETTINGS</p>
<h2 class="page__title">Dark mode</h2>
<p class="page__body">Easier on the eyes</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 · Circle reveal from click point — Measured area 30.981% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
Anchor the circle's origin to a dark-mode toggle or a theme switch, and the reveal reads as if the click itself released the new page.
.page--circle {
clip-path: circle(0% at 88% 14%);
animation: circle-grow $dur-enter $ease-pop;
}
@keyframes circle-grow {
0% { clip-path: circle(0% at 88% 14%); }
100% { clip-path: circle(150% at 88% 14%); }
}
05App-style stack push
What it does
The new screen slides in from the right while the page behind it shrinks a few percent and darkens under a translucent overlay, borrowing the depth cue native app navigation stacks use to show one screen sitting on top of another.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">MENU</p>
<h2 class="page__title">Settings</h2>
<p class="page__body">Account · Notifications · Billing</p>
</section>
<section class="page page--b">
<p class="page__tag">MENU > PUSH</p>
<h2 class="page__title">Notification settings</h2>
<p class="page__body">Push · Email · 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 · App-style stack push — Measured area 31.126% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Reach for it in a mobile web app or any settings flow that should feel like drilling into a stack rather than jumping to an unrelated page.
.page--a { animation: push-back $dur-base $ease-out; transform-origin: left center; }
.page--b { animation: push-front $dur-base $ease-out; transform: translateX(100%); }
.dim { animation: push-dim $dur-base $ease-out; }
@keyframes push-back { 0% { transform: scale(1); } 100% { transform: scale(.94); } }
@keyframes push-front { 0% { transform: translateX(100%); } 100% { transform: translateX(0); } }
@keyframes push-dim { 0% { opacity: 0; } 100% { opacity: .18; } }
06Card to detail zoom
What you see
The clicked card scales up on transform alone until it covers the stage, and a detail panel fades in only near the end of that scale so the enlarging card and the arriving text never fight for attention.

<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">Article body goes here</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 · Card to detail zoom — Measured area 27.268% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Galleries and news feeds use this to keep the clicked item as the visual anchor instead of cutting to an unrelated detail layout.
.cell--focus {
position: relative;
animation: zoom-card $dur-enter $ease-spring;
}
.detail {
opacity: 0;
animation: zoom-detail $dur-enter $ease-spring;
}
@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; }
}
07Tab content cross-fade
The motion
Switching tabs fades the old panel out while nudging it up 8px, and fades the new panel in from 8px below, so the content changes without the tab bar or the surrounding page ever moving.

<main class="stage">
<div class="pages is-demo">
<div class="tabs">
<span class="tab tab--on">General</span>
<span class="tab">Security</span>
</div>
<section class="page page--a">
<p class="page__body">Change language, theme, and notification frequency here</p>
</section>
<section class="page page--b">
<p class="page__body">Change password and two-factor auth here</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 · Tab content cross-fade — Measured area 2.663% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It keeps settings screens and dashboard panels from flashing blank between clicks, since both panels briefly overlap instead of one disappearing before the other starts.
.page--a { animation: tab-a $dur-quick $ease-out; }
.page--b { animation: tab-b $dur-quick $ease-out; opacity: 0; transform: translateY(8px); }
@keyframes tab-a {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-8px); }
}
@keyframes tab-b {
0% { opacity: 0; transform: translateY(8px); }
100% { opacity: 1; transform: translateY(0); }
}
08Animated list reorder
What it does
When the sort order changes, the two rows that swapped places glide to their new row on translateY instead of the list re-rendering in its final order instantly.

<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">Price: low to high</span></div>
<div class="row row--2"><span class="row__rank">2</span><span class="row__name">Highest rated</span></div>
<div class="row row--3"><span class="row__rank">3</span><span class="row__name">Newest first</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 · Animated list reorder — Measured area 2.935% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
The distance each row travels is measured first, then played back as one transform — the same FLIP idea a JS library would run, done here with two keyframe rules. It reads clearly on price sorts and ranking tables, where a row jumping straight to its new spot hides which items actually moved.
.row--1 {
animation: swap-down $dur-base $ease-spring;
}
.row--2 {
animation: swap-up $dur-base $ease-spring;
}
@keyframes swap-down {
0% { transform: translateY(0); }
100% { transform: translateY(34px); }
}
@keyframes swap-up {
0% { transform: translateY(0); }
100% { transform: translateY(-34px); }
}
09Theme dark-mode wipe
What you see
A clip-path polygon grows from a single point in the corner into a triangle big enough to cover the screen, so the page reads as being swept into dark mode on a diagonal edge rather than snapping or fading all at once.

<main class="stage">
<div class="pages is-demo">
<section class="page page--a">
<p class="page__tag">HOME</p>
<h2 class="page__title">Light</h2>
<p class="page__body">Default theme</p>
</section>
<section class="page page--b page--wipe">
<p class="page__tag">HOME</p>
<h2 class="page__title">Dark</h2>
<p class="page__body">Easy-on-the-eyes theme</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 · Theme dark-mode wipe — Measured area 31.016% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Use it on the same toggle or settings screen where a circle reveal would feel too playful for a mode switch.
.page--wipe {
clip-path: polygon(0 0, 0 0, 0 0);
animation: dark-wipe $dur-enter $ease-out;
}
@keyframes dark-wipe {
0% { clip-path: polygon(0 0, 0 0, 0 0); }
100% { clip-path: polygon(0 0, 200% 0, 0 200%); }
}
Where it breaks — the trap
Why the preview loops
Every panel in the grid above is two flat "pages" swapping automatically on a two-second loop, sitting inside a fixed, non-scrollable iframe — watch it run rather than reaching for a click, since nothing in that box is wired to respond to one. That loop stands in for a real navigation event; a router or a button click is what should actually flip these classes on a live page.
The View Transitions trap
The trap that catches people moving past CSS page-transition examples toward the newer API: the browser's native View Transitions API (document.startViewTransition) is not what any of the nine above use — they're all manual transform and clip-path animations that work everywhere CSS animations do. The native API needs feature detection, since same-document support only landed in Chrome 111+, Safari 18+, and Firefox 144+ per MDN's View Transition API browser compatibility table — call it without a support check and older browsers get no transition at all rather than the manual fallback. The nine builds in this post sidestep that gap entirely, at the cost of writing two keyframe blocks per transition instead of naming a view-transition-name and letting the browser interpolate. Zip password, if the manual route is what you came for: amqzkrw2.
Scaled up to a full opening screen, the same morph has to stay readable for much longer than a card-sized one does, and that larger entrance is collected in the CSS hero section animations.
Accessibility
Reduced motion
All nine answer prefers-reduced-motion, but "off" means something different for each. 01, 05, and 07 drop straight to their end opacity and position — no fade, no slide, just the new content already in place. 02's slide and 08's row swap skip the translate entirely and place both elements at their resting coordinates. 03, 04, 06, and 09 — the shape-changing ones — jump straight to their finished clip-path or scale instead of animating through it, since a hero that silently teleports into place beats one frozen mid-morph.
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b, .cell--focus, .detail, .hero, .hero__caption,
.page--circle, .page--wipe, .row--1, .row--2, .dim { animation: none !important; }
}
More css page transitions examples and layout patterns live in the CSS category, and what this site actually gives you is explained on the about page.
FAQ
Are these css page transition effects or the View Transitions API?
They're CSS page transitions in the older, framework-agnostic sense — two stacked elements animated with transform, opacity, and clip-path. None of the nine call document.startViewTransition, which is why they run in any browser that supports CSS animations rather than only the versions listed for the native API.
Why does the demo grid loop instead of waiting for a click?
The grid sits in a small fixed iframe with nothing to navigate to, so each panel is set to swap on a two-second loop automatically for preview purposes. On a real page, the same keyframes would be triggered by a route change or a button, not by a timer.
Can I combine these with React Router or Next.js?
Yes. Every SCSS file here only names classes and keyframes — none of it touches routing — so the router's job stays limited to swapping which component renders, while these classes handle how the outgoing and incoming DOM nodes look for a couple hundred milliseconds. The zip's react/ folder wraps each one in a component that takes the same duration and color as props. When only one region folds open instead of a route changing, no router is involved at all, and the CSS accordion animations cover that case — including why they avoid animating height.









