04전후 비교 와이프
같은 자리에 겹쳐 둔 두 장면 중 위층만 오른쪽부터 잘라 내려 아래층이 드러나고, 잘린 경계에 손잡이 선이 붙어 같이 이동합니다.
쓰임리모델링·보정·성능 개선처럼 '바꾸기 전과 후' 자체가 본문인 사례 소개 구간
clip-path: insetview-timeline-nametranslateX

<main class="stage">
<div class="bw is-demo">
<div class="bw__scroller">
<div class="bw__pad">
<div class="bw__frame">
<div class="bw__layer bw__layer--after" aria-hidden="true">
<span class="bw__bar bw__bar--a"></span>
<span class="bw__bar bw__bar--b"></span>
<span class="bw__bar bw__bar--c"></span>
<span class="bw__dot"></span>
</div>
<div class="bw__layer bw__layer--before" aria-hidden="true">
<span class="bw__bar bw__bar--a"></span>
<span class="bw__bar bw__bar--b"></span>
<span class="bw__bar bw__bar--c"></span>
<span class="bw__dot"></span>
</div>
<div class="bw__handle" aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M208 80v96H48V80Z" opacity=".2"/><path d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-64a8 8 0 0 0 11.32-11.32L67.31 88H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z"/></g></svg>
</div>
</div>
<p class="bw__tags"><span class="bw__tag bw__tag--old">이전</span><span class="bw__tag">이후</span></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴는 2s
// 미리보기 곡선은 가운데가 가장 빠르다 — 대표 이미지(가장 크게 변한 장)가
// 반쯤 걷힌 장면으로 뽑혀야 전과 후가 한 장에 같이 보인다. 실제 스크롤 판은 linear
$easing: cubic-bezier(.65, 0, .35, 1);
$color: $subject-blue;
@include stage($stage-paper);
.bw {
--h: clamp(148px, 58vh, 176px);
--fw: min(272px, 82vw);
width: min(360px, 92vw);
}
.bw__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.bw__pad {
display: grid;
justify-items: center;
gap: $sp-2;
// 상자가 처음에는 화면 밖에 있어야 뷰 타임라인의 entry 구간이 실제로 지나간다
padding: calc(var(--h) * .95) 0 calc(var(--h) * .6);
}
// ⚠ 뷰 타임라인의 이름은 **관찰 대상**(이 상자)에 붙인다. 안쪽 층에서 view() 를 그냥 쓰면
// 이 상자의 overflow: hidden 이 스크롤포트로 잡혀 진행률이 0 에서 멈춘다 (실측)
.bw__frame {
view-timeline-name: --bw-view;
view-timeline-axis: block;
position: relative;
width: var(--fw);
height: 104px;
overflow: hidden;
border-radius: $r-card;
}
.bw__layer {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: $sp-2;
padding: $sp-4;
}
.bw__layer--after { background: #eef3ff; }
.bw__layer--after .bw__bar--a { background: $color; }
.bw__layer--after .bw__bar--b { background: $subject-mint; }
.bw__layer--after .bw__bar--c { background: $subject-lilac; }
.bw__layer--after .bw__dot { background: $stage-orange; }
// ── 실제 동작: 위층만 오른쪽부터 잘려 나간다.
// inset() 의 오른쪽 값이 0 → 100% 로 자라면 아래층이 그만큼 드러난다 ──
.bw__layer--before {
background: #e7e5ea;
clip-path: inset(0 0 0 0);
animation-name: bw-cut;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --bw-view;
}
.bw__layer--before .bw__bar--a,
.bw__layer--before .bw__bar--b,
.bw__layer--before .bw__bar--c { background: #b6b2bd; }
.bw__layer--before .bw__dot { background: #8d8895; }
@keyframes bw-cut {
from { clip-path: inset(0 0 0 0); }
to { clip-path: inset(0 100% 0 0); }
}
.bw__bar {
height: 12px;
border-radius: $r-pill;
}
.bw__bar--a { width: 64%; }
.bw__bar--b { width: 88%; }
.bw__bar--c { width: 46%; }
.bw__dot {
position: absolute;
top: $sp-4;
right: $sp-4;
width: 28px;
height: 28px;
border-radius: 50%;
}
// 손잡이는 잘린 경계에 붙어 같은 진행률로 움직인다
.bw__handle {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: grid;
place-items: center;
width: 4px;
background: $subject-ink;
color: $subject-ink;
animation-name: bw-handle;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --bw-view;
svg {
padding: $sp-1;
border-radius: 50%;
background: $subject-ink;
color: $subject-cream;
}
}
@keyframes bw-handle {
from { transform: translateX(calc(var(--fw) - 4px)); }
to { transform: translateX(0); }
}
.bw__tags {
display: flex;
gap: $sp-2;
margin: 0;
}
.bw__tag {
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $color;
color: #fff;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.bw__tag--old { background: #b6b2bd; }
// 미리보기 루프는 실제로 스크롤하지 않으니 위 여백을 줄여 상자를 화면 안에 둔다
.bw.is-demo .bw__pad { padding-top: calc(var(--h) * .1); }
// ── 격자 미리보기 루프 — 같은 두 애니메이션을 시간으로 되감는다 ──
.bw.is-demo .bw__layer--before,
.bw.is-demo .bw__handle {
animation-duration: $duration;
animation-timing-function: $easing;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@media (prefers-reduced-motion: reduce) {
.bw__layer--before { animation: none !important; clip-path: inset(0 50% 0 0); }
.bw__handle { animation: none !important; transform: translateX(calc(var(--fw) * .5)); }
}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);
}
.bw {
--h: clamp(148px, 58vh, 176px);
--fw: min(272px, 82vw);
width: min(360px, 92vw);
}
.bw__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
}
.bw__scroller::-webkit-scrollbar {
display: none;
}
.bw__pad {
display: grid;
justify-items: center;
gap: 8px;
padding: calc(var(--h) * 0.95) 0 calc(var(--h) * 0.6);
}
.bw__frame {
view-timeline-name: --bw-view;
view-timeline-axis: block;
position: relative;
width: var(--fw);
height: 104px;
overflow: hidden;
border-radius: 16px;
}
.bw__layer {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: 8px;
padding: 16px;
}
.bw__layer--after {
background: #eef3ff;
}
.bw__layer--after .bw__bar--a {
background: #2f6df6;
}
.bw__layer--after .bw__bar--b {
background: #4cd4a6;
}
.bw__layer--after .bw__bar--c {
background: #b9a0ff;
}
.bw__layer--after .bw__dot {
background: #ff4d1f;
}
.bw__layer--before {
background: #e7e5ea;
clip-path: inset(0 0 0 0);
animation-name: bw-cut;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --bw-view;
}
.bw__layer--before .bw__bar--a,
.bw__layer--before .bw__bar--b,
.bw__layer--before .bw__bar--c {
background: #b6b2bd;
}
.bw__layer--before .bw__dot {
background: #8d8895;
}
@keyframes bw-cut {
from {
clip-path: inset(0 0 0 0);
}
to {
clip-path: inset(0 100% 0 0);
}
}
.bw__bar {
height: 12px;
border-radius: 999px;
}
.bw__bar--a {
width: 64%;
}
.bw__bar--b {
width: 88%;
}
.bw__bar--c {
width: 46%;
}
.bw__dot {
position: absolute;
top: 16px;
right: 16px;
width: 28px;
height: 28px;
border-radius: 50%;
}
.bw__handle {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: grid;
place-items: center;
width: 4px;
background: #17141a;
color: #17141a;
animation-name: bw-handle;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --bw-view;
}
.bw__handle svg {
padding: 4px;
border-radius: 50%;
background: #17141a;
color: #fff7e6;
}
@keyframes bw-handle {
from {
transform: translateX(calc(var(--fw) - 4px));
}
to {
transform: translateX(0);
}
}
.bw__tags {
display: flex;
gap: 8px;
margin: 0;
}
.bw__tag {
padding: 4px 8px;
border-radius: 999px;
background: #2f6df6;
color: #fff;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.bw__tag--old {
background: #b6b2bd;
}
.bw.is-demo .bw__pad {
padding-top: calc(var(--h) * 0.1);
}
.bw.is-demo .bw__layer--before,
.bw.is-demo .bw__handle {
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@media (prefers-reduced-motion: reduce) {
.bw__layer--before {
animation: none !important;
clip-path: inset(0 50% 0 0);
}
.bw__handle {
animation: none !important;
transform: translateX(calc(var(--fw) * 0.5));
}
}// 실제 동작은 CSS 만: 위층의 clip-path 와 손잡이 위치를 animation-timeline: view() 가 함께 끌고 간다.
var r = document.querySelector('.bw');
var s = r.querySelector('.bw__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./BeforeAfterWipe.module.scss";
export type BeforeAfterWipeProps = {
beforeLabel?: string;
afterLabel?: string;
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconArrows = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M208 80v96H48V80Z" opacity=".2"/><path d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-64a8 8 0 0 0 11.32-11.32L67.31 88H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z"/></g></svg>
);
const Layer = ({ kind }: { kind: string }): ReactNode => (
<div className={`${styles.layer} ${styles[kind]}`} aria-hidden="true">
<span className={`${styles.bar} ${styles.barA}`} />
<span className={`${styles.bar} ${styles.barB}`} />
<span className={`${styles.bar} ${styles.barC}`} />
<span className={styles.dot} />
</div>
);
export default function BeforeAfterWipe({
beforeLabel = "Before",
afterLabel = "After",
preview = true,
duration = 1000,
easing = "cubic-bezier(.65, 0, .35, 1)",
color = "#2f6df6",
}: BeforeAfterWipeProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.bw} ${styles.isDemo}` : styles.bw;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<div className={styles.frame}>
<Layer kind="after" />
<Layer kind="before" />
<div className={styles.handle} aria-hidden="true"><IconArrows /></div>
</div>
<p className={styles.tags}>
<span className={`${styles.tag} ${styles.tagOld}`}>{beforeLabel}</span>
<span className={styles.tag}>{afterLabel}</span>
</p>
</div>
</div>
</div>
);
}04 전후 비교 와이프 — 측정 누적면적 19.369% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스크롤 스토리텔링 섹션 9종 — 복붙하는 CSS 장면 구성
이 데모가 속한 글
스크롤 스토리텔링 섹션 9종 — 복붙하는 CSS 장면 구성스크롤 스토리텔링은 스크롤 위치를 시간축 삼아 한 구간 안의 장면을 차례로 바꾸는 구성입니다. 여기 아홉 개는 효과가 아니라 장면을 짜는 방식이 서로 다르고, 전부 라이브러리 없이 CSS만으로 돕니다. 이 글의 데모 전부 보기


