02Color band that sweeps across and swaps the content behind it
A color band pushed in one direction covers the screen, and only at that covered moment does the body swap with steps(1, end), so the change never shows through as a translucent overlap.
Best forMenu moves on portfolio sites where each screen is itself a piece of work
translateXsteps(1, end)aria-busy

<main class="stage">
<section class="ws is-demo" id="ws" aria-label="Wipe swap transition preview">
<div class="ws__screen">
<div class="ws__pages" id="ws-pages" aria-busy="false">
<article class="ws__page ws__page--a">
<p class="ws__eyebrow">Work</p>
<p class="ws__head">Twelve photographs</p>
<p class="ws__text">The band comes in from the left and covers the screen.</p>
</article>
<article class="ws__page ws__page--b">
<p class="ws__eyebrow">Notes</p>
<p class="ws__head">How it was made</p>
<p class="ws__text">The content swaps only while it is covered.</p>
</article>
</div>
<span class="ws__band" id="ws-band" aria-hidden="true"></span>
</div>
<button class="ws__go" type="button" id="ws-go">Next screen</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 한 장짜리 색 띠가 좌→우 한 방향으로 훑고 지나가며 그 아래 내용을 갈아 끼운다
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.ws {
box-sizing: border-box;
display: grid;
gap: $sp-3;
width: min(320px, 88vw);
color: $subject-ink;
}
.ws * { box-sizing: border-box; }
.ws__screen {
position: relative;
overflow: hidden;
border: 2px solid $subject-ink;
border-radius: $r-card;
background: $subject-cream;
}
// 두 장을 같은 칸에 겹쳐 둔다 — 자리가 고정이라 갈아 끼워도 아래가 밀리지 않는다
.ws__pages {
display: grid;
min-height: 120px;
padding: $sp-3;
}
.ws__page {
grid-area: 1 / 1;
display: grid;
gap: $sp-2;
align-content: start;
}
.ws__page--b { opacity: 0; }
// 정적(손으로 조작) 경로 — 클래스 하나로 두 장이 컷으로 바뀐다. 반투명으로 겹치는 구간이 없다
.ws__pages.is-b .ws__page--a { opacity: 0; }
.ws__pages.is-b .ws__page--b { opacity: 1; }
.ws__eyebrow {
justify-self: start;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-size: 11px;
font-weight: 800;
line-height: 1;
}
.ws__head {
margin: 0;
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.ws__text {
margin: 0;
font-size: 12px;
font-weight: 500;
line-height: 1.5;
}
// ── 색 띠 ──
// 화면 한 장 크기 그대로 두고 translateX 로만 민다.
// clip-path 로 깎지 않는 이유: steps 타이밍 안에서 clip-path 는 보간되지 않아 위프가 컷으로 죽는다
.ws__band {
position: absolute;
inset: 0;
background: $color;
transform: translateX(-101%);
}
// 실제 클릭 경로 — 한 번만 훑고 지나간다. 한가운데(덮은 순간)에 JS 가 본문을 갈아 끼운다
.ws__band.is-run {
animation-name: ws-once;
animation-duration: $duration * 1.2;
animation-timing-function: $easing;
animation-iteration-count: 1;
animation-fill-mode: both;
}
@keyframes ws-once {
0% { transform: translateX(-101%); }
45%, 55% { transform: translateX(0); }
100% { transform: translateX(101%); }
}
.ws__go {
justify-self: center;
margin: 0;
padding: $sp-2 $sp-4;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
font-weight: 800;
line-height: 1;
cursor: pointer;
}
.ws__go:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 격자 미리보기 전용 2초 루프 ──
// 한 방향 훑기를 두 번(A→B, B→A) 돌린다. 두 번째 훑기가 처음 상태로 돌려놓으므로
// 되돌아가는 장면이 따로 필요 없고, 교체는 언제나 띠 아래에서 일어난다
// 덮고 있는 구간은 짧게, 내용이 보이는 구간은 길게 잡는다.
// 빠져나가는 구간(8%)이 들어오는 구간(22%)보다 짧아서, 프레임당 변화가 가장 큰 장면은
// 언제나 "띠가 빠지며 새 내용이 드러나는 순간" 이 된다 — 대표 이미지가 거기서 잡힌다
@keyframes ws-sweep {
0% { transform: translateX(-101%); }
22%, 25% { transform: translateX(0); }
33% { transform: translateX(101%); animation-timing-function: steps(1, end); }
46% { transform: translateX(-101%); }
68%, 71% { transform: translateX(0); }
79%, 100% { transform: translateX(101%); }
}
// 교체는 띠가 완전히 덮은 구간(22~25% · 68~71%) 안에서 한 프레임에 컷
@keyframes ws-a {
0%, 22% { opacity: 1; }
23%, 68% { opacity: 0; }
69%, 100% { opacity: 1; }
}
@keyframes ws-b {
0%, 22% { opacity: 0; }
23%, 68% { opacity: 1; }
69%, 100% { opacity: 0; }
}
.ws.is-demo {
.ws__band {
animation-name: ws-sweep;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ws__page--a { animation-name: ws-a; }
.ws__page--b { animation-name: ws-b; }
.ws__page {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.ws { gap: $sp-2; }
.ws__pages { min-height: 88px; padding: $sp-2; }
.ws__page { gap: $sp-1; }
.ws__eyebrow { font-size: 10px; }
.ws__head { font-size: 15px; }
.ws__text { font-size: 11px; }
.ws__go { padding: $sp-1 $sp-3; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.ws.is-demo .ws__band,
.ws.is-demo .ws__page--a,
.ws.is-demo .ws__page--b,
.ws__band.is-run { animation: none !important; }
// 움직임만 끄고 "갈아 끼워진 뒤" 결과는 남긴다
.ws.is-demo .ws__band { transform: translateX(-101%) !important; }
.ws.is-demo .ws__page--a { opacity: 0 !important; }
.ws.is-demo .ws__page--b { opacity: 1 !important; }
}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);
}
.ws {
box-sizing: border-box;
display: grid;
gap: 12px;
width: min(320px, 88vw);
color: #17141a;
}
.ws * {
box-sizing: border-box;
}
.ws__screen {
position: relative;
overflow: hidden;
border: 2px solid #17141a;
border-radius: 16px;
background: #fff7e6;
}
.ws__pages {
display: grid;
min-height: 120px;
padding: 12px;
}
.ws__page {
grid-area: 1/1;
display: grid;
gap: 8px;
align-content: start;
}
.ws__page--b {
opacity: 0;
}
.ws__pages.is-b .ws__page--a {
opacity: 0;
}
.ws__pages.is-b .ws__page--b {
opacity: 1;
}
.ws__eyebrow {
justify-self: start;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-size: 11px;
font-weight: 800;
line-height: 1;
}
.ws__head {
margin: 0;
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.ws__text {
margin: 0;
font-size: 12px;
font-weight: 500;
line-height: 1.5;
}
.ws__band {
position: absolute;
inset: 0;
background: #17141a;
transform: translateX(-101%);
}
.ws__band.is-run {
animation-name: ws-once;
animation-duration: 720ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: both;
}
@keyframes ws-once {
0% {
transform: translateX(-101%);
}
45%, 55% {
transform: translateX(0);
}
100% {
transform: translateX(101%);
}
}
.ws__go {
justify-self: center;
margin: 0;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
font-weight: 800;
line-height: 1;
cursor: pointer;
}
.ws__go:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes ws-sweep {
0% {
transform: translateX(-101%);
}
22%, 25% {
transform: translateX(0);
}
33% {
transform: translateX(101%);
animation-timing-function: steps(1, end);
}
46% {
transform: translateX(-101%);
}
68%, 71% {
transform: translateX(0);
}
79%, 100% {
transform: translateX(101%);
}
}
@keyframes ws-a {
0%, 22% {
opacity: 1;
}
23%, 68% {
opacity: 0;
}
69%, 100% {
opacity: 1;
}
}
@keyframes ws-b {
0%, 22% {
opacity: 0;
}
23%, 68% {
opacity: 1;
}
69%, 100% {
opacity: 0;
}
}
.ws.is-demo .ws__band {
animation-name: ws-sweep;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ws.is-demo .ws__page--a {
animation-name: ws-a;
}
.ws.is-demo .ws__page--b {
animation-name: ws-b;
}
.ws.is-demo .ws__page {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.ws {
gap: 8px;
}
.ws__pages {
min-height: 88px;
padding: 8px;
}
.ws__page {
gap: 4px;
}
.ws__eyebrow {
font-size: 10px;
}
.ws__head {
font-size: 15px;
}
.ws__text {
font-size: 11px;
}
.ws__go {
padding: 4px 12px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ws.is-demo .ws__band,
.ws.is-demo .ws__page--a,
.ws.is-demo .ws__page--b,
.ws__band.is-run {
animation: none !important;
}
.ws.is-demo .ws__band {
transform: translateX(-101%) !important;
}
.ws.is-demo .ws__page--a {
opacity: 0 !important;
}
.ws.is-demo .ws__page--b {
opacity: 1 !important;
}
}// 띠가 화면을 완전히 덮은 순간(애니메이션 한가운데)에만 본문을 갈아 끼운다.
var ws = document.getElementById('ws');
var wsBand = document.getElementById('ws-band');
var wsPages = document.getElementById('ws-pages');
var wsGo = document.getElementById('ws-go');
var wsBusy = false;
function wsSweep() {
ws.classList.remove('is-demo');
if (wsBusy) return;
wsBusy = true;
wsPages.setAttribute('aria-busy', 'true');
wsBand.classList.remove('is-run');
void wsBand.offsetWidth;
wsBand.classList.add('is-run');
setTimeout(function () { wsPages.classList.toggle('is-b'); }, 360);
setTimeout(function () {
wsBand.classList.remove('is-run');
wsPages.setAttribute('aria-busy', 'false');
wsBusy = false;
}, 740);
}
wsGo.addEventListener('click', wsSweep);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WipeSwapTransition.module.scss";
export type WipePage = { eyebrow: string; head: string; text: string };
export type WipeSwapTransitionProps = {
/** 갈아 끼울 두 화면. 띠가 완전히 덮은 순간에만 바뀐다 */
pages?: [WipePage, WipePage];
buttonLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 useRef 가 T | null 을 돌려준다 — 필요한 꼴만 로컬 타입으로 단언
type Box<T> = { current: T };
const DEFAULT_PAGES: [WipePage, WipePage] = [
{ eyebrow: "Work", head: "Twelve photographs", text: "The band comes in from the left and covers the screen." },
{ eyebrow: "Notes", head: "How it was made", text: "The content swaps only while it is covered." },
];
export default function WipeSwapTransition({
pages = DEFAULT_PAGES,
buttonLabel = "Next screen",
groupLabel = "Wipe swap transition preview",
demo = true,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: WipeSwapTransitionProps) {
const [live, setLive] = useState(demo);
const [second, setSecond] = useState(false);
const [busy, setBusy] = useState(false);
const [run, setRun] = useState(false);
const timers = useRef<number[]>([]) as Box<number[]>;
useEffect(() => () => {
for (const t of timers.current) clearTimeout(t);
}, []);
function sweep() {
setLive(false);
if (busy) return;
setBusy(true);
setRun(false);
// 한 프레임 뒤에 켜야 애니메이션이 처음부터 다시 돈다
const a = window.setTimeout(() => setRun(true), 20);
// 띠가 화면을 완전히 덮은 한가운데에서만 본문을 갈아 끼운다
const b = window.setTimeout(() => setSecond((v) => !v), 380);
const c = window.setTimeout(() => {
setRun(false);
setBusy(false);
}, 780);
timers.current = [a, b, c];
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.ws, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const pageCls = [styles.pages, second ? styles.isB : ""].filter(Boolean).join(" ");
const bandCls = [styles.band, run ? styles.isRun : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.screen}>
<div className={pageCls} aria-busy={busy}>
{pages.map((p, i) => (
<article className={[styles.page, i === 0 ? styles.pageA : styles.pageB].join(" ")} key={p.head}>
<p className={styles.eyebrow}>{p.eyebrow}</p>
<p className={styles.head}>{p.head}</p>
<p className={styles.text}>{p.text}</p>
</article>
))}
</div>
<span className={bandCls} aria-hidden="true" />
</div>
<button className={styles.go} type="button" onClick={sweep}>{buttonLabel}</button>
</section>
);
}02 Color band that sweeps across and swaps the content behind it — Measured area 25.842% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Page Loading Animation CSS: 9 Route-Change Demos
The post this demo lives in
Page Loading Animation CSS: 9 Route-Change DemosPage loading animation css is the feedback layer covering the moment one screen gives way to the next. The nine here are See all demos in this post
If you like this effect - similar demos
Loaders01 Text That Swaps a Fallback Font for the Real One via Lazy LoadingLazy Loading Images: 9 CSS Placeholder Patterns
Loaders07 Button That Dims and Locks Out Clicks While LoadingLoading Button UI — 9 States, Spinner to Success
Loaders09 Chart Loading That Skips Computation and Swaps Instantly Under Reduced MotionChart Loading Animation — 9 Computed Patterns