이미지 비교 슬라이더 UI 9종 — 드래그하면 바로 보이는
이미지 비교 슬라이더는 사진 한 장 위에 다른 사진을 겹쳐 놓고, 경계선을 움직이면 전과 후가 갈라져 보이는 UI입니다. 리모델링·시술 후기 페이지에서 흔히 보지만 클릭 한 번에 바로 켜지는 동작이라 CSS만으로는 못 만들고 JS 몇 줄이 꼭 필요합니다.
01 before-after-slider-click-reveal
02 before-after-slider-drag-handle
03 before-after-slider-touch-swipe
04 before-after-slider-keyboard-a11y
05 before-after-slider-vertical
06 before-after-slider-labels
07 before-after-slider-caption-overlay
08 before-after-slider-zoom-sync
09 before-after-slider-multi-image-carousel
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 클릭 비교 슬라이더
- 02 드래그 핸들 비교 슬라이더
- 03 터치 스와이프 비교 슬라이더
- 04 키보드 접근성 비교 슬라이더
- 05 세로 방향 비교 슬라이더
- 06 BEFORE·AFTER 라벨 배지
- 07 캡션 오버레이 비교 슬라이더
- 08 줌 동기화 비교 슬라이더
- 09 여러 장 비교 캐러셀
아홉 개는 예쁜 순서가 아니라 이 슬라이더가 실제로 겪는 순서대로 놓았습니다. html 이미지 호버 효과처럼 클릭 한 번으로 켜보고 → 손으로 계속 끌어보고 → 모바일에서 눌러보고 → 키보드로도 되는지 확인하고 → 방향을 세로로 바꿔보고 → 라벨을 달아보고 → 설명 문구를 붙여보고 → 확대해보고 → 마지막엔 여러 장을 한 페이지에 늘어놓습니다. 겹쳐진 두 장 대신 사진 여러 장을 벽에 거는 쪽은 이미지 갤러리 CSS 9종이고, 마우스로 순서를 다루는 쪽은 포트폴리오 갤러리 UI 9종입니다.
01클릭 비교 슬라이더
css 슬라이더로 무엇이 보이나
사진 아무 곳이나 클릭하면 그 자리로 경계선이 바로 옮겨갑니다.

<main class="stage">
<div class="ba is-demo" id="ba" role="img" aria-label="클릭하면 경계가 그 위치로 옮겨가는 전후 비교">
<div class="ba__after"></div>
<div class="ba__before"></div>
<div class="ba__line"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-ink);
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
cursor: pointer;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after { background: linear-gradient(155deg, $color 0%, $subject-cream 100%); }
.ba__before {
background: linear-gradient(155deg, $stage-ink 0%, $color 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
transition: clip-path $duration $easing;
}
.ba__line {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: $sp-1;
transform: translateX(-50%);
background: $subject-cream;
transition: left $duration $easing;
}
.ba.is-demo .ba__before,
.ba.is-demo .ba__line { transition: none; }
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes reveal-loop {
0%, 8% { --reveal: 22%; }
50% { --reveal: 78%; }
92%, 100% { --reveal: 22%; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; }
.ba__before, .ba__line { transition: none; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after {
background: linear-gradient(155deg, #2f6df6 0%, #fff7e6 100%);
}
.ba__before {
background: linear-gradient(155deg, #17141a 0%, #2f6df6 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
transition: clip-path 2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ba__line {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: 4px;
transform: translateX(-50%);
background: #fff7e6;
transition: left 2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ba.is-demo .ba__before,
.ba.is-demo .ba__line {
transition: none;
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 22%;
}
50% {
--reveal: 78%;
}
92%, 100% {
--reveal: 22%;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
}
.ba__before, .ba__line {
transition: none;
}
}(function () {
var el = document.getElementById("ba");
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
el.style.setProperty("--reveal", pct + "%");
}
el.addEventListener("click", function (e) {
el.classList.remove("is-demo");
var r = el.getBoundingClientRect();
set(((e.clientX - r.left) / r.width) * 100);
});
})();import { useRef, useState, useCallback } from "react";
import type { CSSProperties, MouseEvent } from "react";
import styles from "./BeforeAfterClickReveal.module.scss";
export type BeforeAfterClickRevealProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--reveal"?: string };
/**
* 사진 아무 곳이나 클릭하면 그 x좌표로 경계선이 바로 옮겨간다.
* clip-path 로 before 레이어를 잘라 after 위에 얹는다 — 드래그 없이 클릭 한 번.
*/
export default function BeforeAfterClickReveal({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BeforeAfterClickRevealProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [reveal, setReveal] = useState(50);
const [demo, setDemo] = useState(true);
const jump = useCallback((e: MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const pct = ((e.clientX - r.left) / r.width) * 100;
setDemo(false);
setReveal(Math.max(0, Math.min(100, pct)));
}, []);
const vars: Vars = { "--reveal": `${reveal}%`, ["--duration" as string]: duration, ["--easing" as string]: easing, ["--color" as string]: color };
return (
<div
ref={ref}
className={demo ? `${styles.ba} ${styles.isDemo}` : styles.ba}
style={vars}
onClick={jump}
role="img"
aria-label="클릭하면 경계가 옮겨가는 전후 비교"
>
<div className={styles.after} />
<div className={styles.before} />
<div className={styles.line} />
</div>
);
}01 · 클릭 비교 슬라이더 — 측정 누적면적 12.083% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
드래그가 아니라 클릭 한 번이라 가장 먼저 시험해보기 좋은 가장 단순한 버전입니다.
el.addEventListener("click", function (e) {
el.classList.remove("is-demo");
var r = el.getBoundingClientRect();
var pct = ((e.clientX - r.left) / r.width) * 100;
el.style.setProperty("--reveal", Math.max(0, Math.min(100, pct)) + "%");
});
02드래그 핸들 비교 슬라이더
화면에서 일어나는 일
가운데 원형 핸들을 손가락처럼 끌면 세로선이 실시간으로 따라옵니다.

<main class="stage">
<div class="ba is-demo" id="ba">
<div class="ba__after"></div>
<div class="ba__before"></div>
<div class="ba__handle" id="grip"><span class="ba__grip"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-yellow);
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after { background: linear-gradient(155deg, $subject-cream 0%, $color 100%); }
.ba__before {
background: linear-gradient(155deg, $color 0%, $subject-cream 65%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: $sp-1;
transform: translateX(-50%);
background: $stage-yellow;
cursor: ew-resize;
touch-action: none;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
background: $stage-yellow;
border: $sp-1 solid $color;
transform: translate(-50%, -50%);
box-shadow: $shadow-press;
}
.ba.is-demo .ba__handle { pointer-events: none; }
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes reveal-loop {
0%, 8% { --reveal: 20%; }
50% { --reveal: 80%; }
92%, 100% { --reveal: 20%; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after {
background: linear-gradient(155deg, #fff7e6 0%, #17141a 100%);
}
.ba__before {
background: linear-gradient(155deg, #17141a 0%, #fff7e6 65%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: 4px;
transform: translateX(-50%);
background: #ffd23f;
cursor: ew-resize;
touch-action: none;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: 32px;
height: 32px;
border-radius: 999px;
background: #ffd23f;
border: 4px solid #17141a;
transform: translate(-50%, -50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ba.is-demo .ba__handle {
pointer-events: none;
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 20%;
}
50% {
--reveal: 80%;
}
92%, 100% {
--reveal: 20%;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
}
}(function () {
var el = document.getElementById("ba");
var grip = document.getElementById("grip");
var dragging = false;
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
el.style.setProperty("--reveal", pct + "%");
}
function fromX(x) {
var r = el.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
grip.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
grip.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
grip.addEventListener("pointermove", function (e) {
if (dragging) set(fromX(e.clientX));
});
grip.addEventListener("pointerup", function () { dragging = false; });
grip.addEventListener("pointercancel", function () { dragging = false; });
})();import { useRef, useState, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BeforeAfterDragHandle.module.scss";
export type BeforeAfterDragHandleProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--reveal"?: string };
/** 원형 핸들을 드래그하면 세로선이 실시간으로 따라온다. pointer capture 로 손이 빠르게 움직여도 안 놓친다. */
export default function BeforeAfterDragHandle({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: BeforeAfterDragHandleProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [reveal, setReveal] = useState(50);
const [demo, setDemo] = useState(true);
const dragging = useRef(false);
const setFromX = useCallback((x: number) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const pct = ((x - r.left) / r.width) * 100;
setReveal(Math.max(0, Math.min(100, pct)));
}, []);
const onDown = useCallback((e: PointerEvent<HTMLSpanElement>) => {
setDemo(false);
dragging.current = true;
(e.currentTarget as any).setPointerCapture?.(e.pointerId);
setFromX(e.clientX);
}, [setFromX]);
const onMove = useCallback((e: PointerEvent<HTMLSpanElement>) => {
if (dragging.current) setFromX(e.clientX);
}, [setFromX]);
const onUp = useCallback(() => { dragging.current = false; }, []);
const vars: Vars = { "--reveal": `${reveal}%`, ["--duration" as string]: duration, ["--easing" as string]: easing, ["--color" as string]: color };
return (
<div ref={ref} className={demo ? `${styles.ba} ${styles.isDemo}` : styles.ba} style={vars}>
<div className={styles.after} />
<div className={styles.before} />
<div className={styles.handle}>
<span
className={styles.grip}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
/>
</div>
</div>
);
}02 · 드래그 핸들 비교 슬라이더 — 측정 누적면적 13.55% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
대부분의 상세페이지가 쓰는 기본형이 이 버전입니다.
grip.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
grip.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
grip.addEventListener("pointermove", function (e) {
if (dragging) set(fromX(e.clientX));
});
03터치 스와이프 비교 슬라이더
어떤 움직임인가
슬라이더 전체가 터치 타깃이라 핸들의 작은 점을 정확히 짚을 필요가 없습니다.

<main class="stage">
<div class="ba is-demo" id="ba">
<div class="ba__after"></div>
<div class="ba__before"></div>
<div class="ba__handle"><span class="ba__grip"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-orange);
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
touch-action: none;
cursor: grab;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after { background: linear-gradient(155deg, $color 0%, $stage-orange 100%); }
.ba__before {
background: linear-gradient(155deg, $stage-ink 0%, $stage-orange 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
// 손끝이 굵어 명중률이 낮은 터치라 핸들을 얇게 두지 않고 48px 그립으로 키운다
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: $sp-1;
transform: translateX(-50%);
background: $color;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: $sp-12;
height: $sp-12;
border-radius: $r-pill;
background: $color;
transform: translate(-50%, -50%);
box-shadow: $shadow-press;
}
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes reveal-loop {
0%, 8% { --reveal: 24%; }
50% { --reveal: 76%; }
92%, 100% { --reveal: 24%; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: grab;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after {
background: linear-gradient(155deg, #fff7e6 0%, #ff4d1f 100%);
}
.ba__before {
background: linear-gradient(155deg, #17141a 0%, #ff4d1f 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: 4px;
transform: translateX(-50%);
background: #fff7e6;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: 48px;
height: 48px;
border-radius: 999px;
background: #fff7e6;
transform: translate(-50%, -50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 24%;
}
50% {
--reveal: 76%;
}
92%, 100% {
--reveal: 24%;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
}
}(function () {
var el = document.getElementById("ba");
var dragging = false;
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
el.style.setProperty("--reveal", pct + "%");
}
function fromX(x) {
var r = el.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
el.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
el.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
el.addEventListener("pointermove", function (e) {
if (dragging) set(fromX(e.clientX));
});
el.addEventListener("pointerup", function () { dragging = false; });
el.addEventListener("pointercancel", function () { dragging = false; });
})();import { useRef, useState, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BeforeAfterTouchSwipe.module.scss";
export type BeforeAfterTouchSwipeProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--reveal"?: string };
/** 슬라이더 전체가 터치 타깃이다. setPointerCapture 로 손가락이 밖으로 나가도 계속 따라온다. */
export default function BeforeAfterTouchSwipe({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: BeforeAfterTouchSwipeProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [reveal, setReveal] = useState(50);
const [demo, setDemo] = useState(true);
const dragging = useRef(false);
const setFromX = useCallback((x: number) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const pct = ((x - r.left) / r.width) * 100;
setReveal(Math.max(0, Math.min(100, pct)));
}, []);
const onDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDemo(false);
dragging.current = true;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
setFromX(e.clientX);
}, [setFromX]);
const onMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (dragging.current) setFromX(e.clientX);
}, [setFromX]);
const onUp = useCallback(() => { dragging.current = false; }, []);
const vars: Vars = { "--reveal": `${reveal}%`, ["--duration" as string]: duration, ["--easing" as string]: easing, ["--color" as string]: color };
return (
<div
ref={ref}
className={demo ? `${styles.ba} ${styles.isDemo}` : styles.ba}
style={vars}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
>
<div className={styles.after} />
<div className={styles.before} />
<div className={styles.handle}>
<span className={styles.grip} />
</div>
</div>
);
}03 · 터치 스와이프 비교 슬라이더 — 측정 누적면적 12.826% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
그립도 48px로 키워 손끝이 굵은 사람도 잘 누를 수 있습니다.
.ba {
touch-action: none;
cursor: grab;
}
.ba__grip {
width: $sp-12; // 48px — 손끝 굵기를 고려한 최소 터치 크기
height: $sp-12;
border-radius: $r-pill;
}
04키보드 접근성 비교 슬라이더
무엇이 보이나
Tab으로 초점을 옮긴 뒤 방향키를 누르면 5%씩, Home·End를 누르면 양 끝으로 옮겨갑니다.

<main class="stage">
<div class="ba is-demo" id="ba"
role="slider" tabindex="0"
aria-label="이전/이후 비교 슬라이더"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
<div class="ba__after"></div>
<div class="ba__before"></div>
<div class="ba__handle"><span class="ba__grip"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-paper);
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
touch-action: none;
outline: none;
--reveal: 50%;
}
.ba:focus-visible {
box-shadow: $shadow-raised, 0 0 0 $sp-1 $color;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after { background: linear-gradient(155deg, $subject-cream 0%, $color 100%); }
.ba__before {
background: linear-gradient(155deg, $subject-ink 0%, $color 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: $sp-1;
transform: translateX(-50%);
background: $subject-cream;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
background: $subject-cream;
transform: translate(-50%, -50%);
box-shadow: $shadow-press;
}
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes reveal-loop {
0%, 8% { --reveal: 20%; }
50% { --reveal: 80%; }
92%, 100% { --reveal: 20%; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
outline: none;
--reveal: 50%;
}
.ba:focus-visible {
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), 0 0 0 4px #2f6df6;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after {
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
}
.ba__before {
background: linear-gradient(155deg, #17141a 0%, #2f6df6 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: 4px;
transform: translateX(-50%);
background: #fff7e6;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: 32px;
height: 32px;
border-radius: 999px;
background: #fff7e6;
transform: translate(-50%, -50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 20%;
}
50% {
--reveal: 80%;
}
92%, 100% {
--reveal: 20%;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
}
}(function () {
var el = document.getElementById("ba");
var dragging = false;
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
el.style.setProperty("--reveal", pct + "%");
el.setAttribute("aria-valuenow", String(Math.round(pct)));
}
function cur() {
return parseFloat(getComputedStyle(el).getPropertyValue("--reveal")) || 50;
}
function fromX(x) {
var r = el.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
el.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
el.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
el.addEventListener("pointermove", function (e) { if (dragging) set(fromX(e.clientX)); });
el.addEventListener("pointerup", function () { dragging = false; });
el.addEventListener("keydown", function (e) {
el.classList.remove("is-demo");
if (e.key === "ArrowLeft") { set(cur() - 5); e.preventDefault(); }
else if (e.key === "ArrowRight") { set(cur() + 5); e.preventDefault(); }
else if (e.key === "Home") { set(0); e.preventDefault(); }
else if (e.key === "End") { set(100); e.preventDefault(); }
});
})();import { useRef, useState, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BeforeAfterKeyboardA11y.module.scss";
export type BeforeAfterKeyboardA11yProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--reveal"?: string };
type KeyEvent = { key: string; preventDefault: () => void; [k: string]: any };
/** role="slider" + 방향키 5%·Home·End 로 마우스 없이도 조작한다. aria-valuenow 가 실시간으로 갱신된다. */
export default function BeforeAfterKeyboardA11y({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BeforeAfterKeyboardA11yProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [reveal, setReveal] = useState(50);
const [demo, setDemo] = useState(true);
const dragging = useRef(false);
const clamp = (v: number) => Math.max(0, Math.min(100, v));
const setFromX = useCallback((x: number) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
setReveal(clamp(((x - r.left) / r.width) * 100));
}, []);
const onDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDemo(false);
dragging.current = true;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
setFromX(e.clientX);
}, [setFromX]);
const onMove = useCallback((e: PointerEvent<HTMLDivElement>) => { if (dragging.current) setFromX(e.clientX); }, [setFromX]);
const onUp = useCallback(() => { dragging.current = false; }, []);
const onKeyDown = useCallback((e: KeyEvent) => {
setDemo(false);
if (e.key === "ArrowLeft") { setReveal((v) => clamp(v - 5)); e.preventDefault(); }
else if (e.key === "ArrowRight") { setReveal((v) => clamp(v + 5)); e.preventDefault(); }
else if (e.key === "Home") { setReveal(0); e.preventDefault(); }
else if (e.key === "End") { setReveal(100); e.preventDefault(); }
}, []);
const vars: Vars = { "--reveal": `${reveal}%`, ["--duration" as string]: duration, ["--easing" as string]: easing, ["--color" as string]: color };
return (
<div
ref={ref}
className={demo ? `${styles.ba} ${styles.isDemo}` : styles.ba}
style={vars}
role="slider"
tabIndex={0}
aria-label="이전/이후 비교 슬라이더"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(reveal)}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onKeyDown={onKeyDown}
>
<div className={styles.after} />
<div className={styles.before} />
<div className={styles.handle}>
<span className={styles.grip} />
</div>
</div>
);
}04 · 키보드 접근성 비교 슬라이더 — 측정 누적면적 13.651% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
마우스 없이도 전후를 비교할 수 있습니다.
el.addEventListener("keydown", function (e) {
el.classList.remove("is-demo");
if (e.key === "ArrowLeft") { set(cur() - 5); e.preventDefault(); }
else if (e.key === "ArrowRight") { set(cur() + 5); e.preventDefault(); }
else if (e.key === "Home") { set(0); e.preventDefault(); }
else if (e.key === "End") { set(100); e.preventDefault(); }
});
05세로 방향 비교 슬라이더
화면에서 일어나는 일
경계선이 좌우 대신 위아래로 움직여, 세로로 긴 사진을 비교할 때 원본을 회전하지 않아도 됩니다.

<main class="stage">
<div class="ba is-demo" id="ba">
<div class="ba__after"></div>
<div class="ba__before"></div>
<div class="ba__handle"><span class="ba__grip"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-ink);
.ba {
position: relative;
width: 120px;
max-width: 100%;
aspect-ratio: 3 / 4;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
touch-action: none;
cursor: ns-resize;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after { background: linear-gradient(200deg, $color 0%, $subject-cream 100%); }
.ba__before {
background: linear-gradient(200deg, $stage-ink 0%, $color 70%);
clip-path: inset(0 0 calc(100% - var(--reveal)) 0);
}
.ba__handle {
position: absolute;
left: 0;
right: 0;
top: var(--reveal);
height: $sp-1;
transform: translateY(-50%);
background: $subject-cream;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
background: $subject-cream;
transform: translate(-50%, -50%);
box-shadow: $shadow-press;
}
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes reveal-loop {
0%, 8% { --reveal: 22%; }
50% { --reveal: 78%; }
92%, 100% { --reveal: 22%; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.ba {
position: relative;
width: 120px;
max-width: 100%;
aspect-ratio: 3/4;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: ns-resize;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after {
background: linear-gradient(200deg, #2f6df6 0%, #fff7e6 100%);
}
.ba__before {
background: linear-gradient(200deg, #17141a 0%, #2f6df6 70%);
clip-path: inset(0 0 calc(100% - var(--reveal)) 0);
}
.ba__handle {
position: absolute;
left: 0;
right: 0;
top: var(--reveal);
height: 4px;
transform: translateY(-50%);
background: #fff7e6;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: 32px;
height: 32px;
border-radius: 999px;
background: #fff7e6;
transform: translate(-50%, -50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 22%;
}
50% {
--reveal: 78%;
}
92%, 100% {
--reveal: 22%;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
}
}(function () {
var el = document.getElementById("ba");
var dragging = false;
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
el.style.setProperty("--reveal", pct + "%");
}
function fromY(y) {
var r = el.getBoundingClientRect();
return ((y - r.top) / r.height) * 100;
}
el.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
el.setPointerCapture(e.pointerId);
set(fromY(e.clientY));
});
el.addEventListener("pointermove", function (e) { if (dragging) set(fromY(e.clientY)); });
el.addEventListener("pointerup", function () { dragging = false; });
})();import { useRef, useState, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BeforeAfterVertical.module.scss";
export type BeforeAfterVerticalProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--reveal"?: string };
/** 경계선이 위아래로 움직인다. clip-path: inset(var(--reveal) 0 0 0) 로 위쪽 사진이 아래를 덮는다. */
export default function BeforeAfterVertical({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BeforeAfterVerticalProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [reveal, setReveal] = useState(50);
const [demo, setDemo] = useState(true);
const dragging = useRef(false);
const setFromY = useCallback((y: number) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
setReveal(Math.max(0, Math.min(100, ((y - r.top) / r.height) * 100)));
}, []);
const onDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDemo(false);
dragging.current = true;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
setFromY(e.clientY);
}, [setFromY]);
const onMove = useCallback((e: PointerEvent<HTMLDivElement>) => { if (dragging.current) setFromY(e.clientY); }, [setFromY]);
const onUp = useCallback(() => { dragging.current = false; }, []);
const vars: Vars = { "--reveal": `${reveal}%`, ["--duration" as string]: duration, ["--easing" as string]: easing, ["--color" as string]: color };
return (
<div
ref={ref}
className={demo ? `${styles.ba} ${styles.isDemo}` : styles.ba}
style={vars}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
>
<div className={styles.after} />
<div className={styles.before} />
<div className={styles.handle}>
<span className={styles.grip} />
</div>
</div>
);
}05 · 세로 방향 비교 슬라이더 — 측정 누적면적 8.635% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
.ba__before {
clip-path: inset(0 0 calc(100% - var(--reveal)) 0);
}
.ba__handle {
top: var(--reveal);
height: $sp-1;
transform: translateY(-50%);
}
06BEFORE·AFTER 라벨 배지
어떤 움직임인가
좌우 위쪽에 고정된 알약 배지가 경계선 위치에 맞춰 opacity로 옅어지고 짙어져, 지금 보는 쪽이 전인지 후인지 헷갈리지 않게 합니다.

<main class="stage">
<div class="ba is-demo" id="ba">
<div class="ba__after"></div>
<div class="ba__before"></div>
<span class="ba__badge ba__badge--before">BEFORE</span>
<span class="ba__badge ba__badge--after">AFTER</span>
<div class="ba__handle"><span class="ba__grip"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --reveal-num {
syntax: "<number>";
inherits: true;
initial-value: 0.5;
}
@include stage($stage-yellow);
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
touch-action: none;
cursor: ew-resize;
font: 700 12px/1 system-ui, sans-serif;
--reveal: 50%;
--reveal-num: 0.5;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after { background: linear-gradient(155deg, $subject-cream 0%, $color 100%); }
.ba__before {
background: linear-gradient(155deg, $color 0%, $subject-cream 65%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__badge {
position: absolute;
top: $sp-3;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $stage-yellow;
color: $color;
letter-spacing: .04em;
}
// 뱃지는 통째로 페이드된다 — opacity 가 옐로 면과 잉크 글자에 함께 걸려 둘의 차이까지 같이 옅어진다.
// 0~1 전 구간을 쓰면 손잡이가 가운데(.5)일 때 2.44~2.90:1 이라, 알파 하한을 .72 로 둔다 (그 지점에서도 8.6:1 이상)
.ba__badge--before { left: $sp-3; opacity: calc(.72 + .28 * var(--reveal-num)); }
.ba__badge--after { right: $sp-3; opacity: calc(1 - .28 * var(--reveal-num)); }
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: $sp-1;
transform: translateX(-50%);
background: $stage-yellow;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
background: $stage-yellow;
transform: translate(-50%, -50%);
box-shadow: $shadow-press;
}
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes reveal-loop {
0%, 8% { --reveal: 20%; --reveal-num: 0.2; }
50% { --reveal: 80%; --reveal-num: 0.8; }
92%, 100% { --reveal: 20%; --reveal-num: 0.2; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; --reveal-num: 0.5; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --reveal-num {
syntax: "<number>";
inherits: true;
initial-value: 0.5;
}
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);
}
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: ew-resize;
font: 700 12px/1 system-ui, sans-serif;
--reveal: 50%;
--reveal-num: 0.5;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after {
background: linear-gradient(155deg, #fff7e6 0%, #17141a 100%);
}
.ba__before {
background: linear-gradient(155deg, #17141a 0%, #fff7e6 65%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__badge {
position: absolute;
top: 12px;
padding: 4px 12px;
border-radius: 999px;
background: #ffd23f;
color: #17141a;
letter-spacing: 0.04em;
}
.ba__badge--before {
left: 12px;
opacity: calc(0.72 + 0.28 * var(--reveal-num));
}
.ba__badge--after {
right: 12px;
opacity: calc(1 - 0.28 * var(--reveal-num));
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: 4px;
transform: translateX(-50%);
background: #ffd23f;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: 32px;
height: 32px;
border-radius: 999px;
background: #ffd23f;
transform: translate(-50%, -50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 20%;
--reveal-num: 0.2;
}
50% {
--reveal: 80%;
--reveal-num: 0.8;
}
92%, 100% {
--reveal: 20%;
--reveal-num: 0.2;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
--reveal-num: 0.5;
}
}(function () {
var el = document.getElementById("ba");
var dragging = false;
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
el.style.setProperty("--reveal", pct + "%");
el.style.setProperty("--reveal-num", (pct / 100).toFixed(3));
}
function fromX(x) {
var r = el.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
el.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
el.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
el.addEventListener("pointermove", function (e) { if (dragging) set(fromX(e.clientX)); });
el.addEventListener("pointerup", function () { dragging = false; });
})();import { useRef, useState, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BeforeAfterLabels.module.scss";
export type BeforeAfterLabelsProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--reveal"?: string; "--revealNum"?: string };
/** BEFORE·AFTER 알약 배지가 경계선 위치에 맞춰 opacity 로 옅어지고 짙어진다. */
export default function BeforeAfterLabels({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: BeforeAfterLabelsProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [reveal, setReveal] = useState(50);
const [demo, setDemo] = useState(true);
const dragging = useRef(false);
const setFromX = useCallback((x: number) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
setReveal(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}, []);
const onDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDemo(false);
dragging.current = true;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
setFromX(e.clientX);
}, [setFromX]);
const onMove = useCallback((e: PointerEvent<HTMLDivElement>) => { if (dragging.current) setFromX(e.clientX); }, [setFromX]);
const onUp = useCallback(() => { dragging.current = false; }, []);
const vars: Vars = {
"--reveal": `${reveal}%`,
"--revealNum": (reveal / 100).toFixed(3),
["--duration" as string]: duration,
["--easing" as string]: easing,
["--color" as string]: color,
};
return (
<div
ref={ref}
className={demo ? `${styles.ba} ${styles.isDemo}` : styles.ba}
style={vars}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
>
<div className={styles.after} />
<div className={styles.before} />
<span className={`${styles.badge} ${styles.badgeBefore}`}>BEFORE</span>
<span className={`${styles.badge} ${styles.badgeAfter}`}>AFTER</span>
<div className={styles.handle}>
<span className={styles.grip} />
</div>
</div>
);
}06 · BEFORE·AFTER 라벨 배지 — 측정 누적면적 13.842% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
// 위치(%)와 0~1 사이 숫자를 각각 등록해서 opacity 는 숫자 쪽을 쓴다
el.style.setProperty("--reveal", pct + "%");
el.style.setProperty("--reveal-num", (pct / 100).toFixed(3));
}
07캡션 오버레이 비교 슬라이더
무엇이 보이나
하단에 반투명 그러데이션 띠가 깔리고 "3주 경과" 같은 캡션 문구가 함께 떠, 무엇을 비교하는 사진인지 말로도 설명해 줍니다.

<main class="stage">
<div class="ba is-demo" id="ba">
<div class="ba__after"></div>
<div class="ba__before"></div>
<div class="ba__caption">3주 경과</div>
<div class="ba__handle"><span class="ba__grip"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-orange);
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
touch-action: none;
cursor: ew-resize;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after { background: linear-gradient(155deg, $color 0%, $stage-orange 100%); }
.ba__before {
background: linear-gradient(155deg, $subject-ink 0%, $stage-orange 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: $sp-6 $sp-4 $sp-3;
background: linear-gradient(to top, rgba(23, 20, 26, .78) 0%, rgba(23, 20, 26, 0) 100%);
color: $color;
font: 700 13px/1.3 system-ui, sans-serif;
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: $sp-1;
transform: translateX(-50%);
background: $color;
}
.ba__grip {
position: absolute;
top: 40%;
left: 50%;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
background: $color;
transform: translate(-50%, -50%);
box-shadow: $shadow-press;
}
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes reveal-loop {
0%, 8% { --reveal: 24%; }
50% { --reveal: 76%; }
92%, 100% { --reveal: 24%; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: ew-resize;
--reveal: 50%;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after {
background: linear-gradient(155deg, #fff7e6 0%, #ff4d1f 100%);
}
.ba__before {
background: linear-gradient(155deg, #17141a 0%, #ff4d1f 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 24px 16px 12px;
background: linear-gradient(to top, rgba(23, 20, 26, 0.78) 0%, rgba(23, 20, 26, 0) 100%);
color: #fff7e6;
font: 700 13px/1.3 system-ui, sans-serif;
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: 4px;
transform: translateX(-50%);
background: #fff7e6;
}
.ba__grip {
position: absolute;
top: 40%;
left: 50%;
width: 32px;
height: 32px;
border-radius: 999px;
background: #fff7e6;
transform: translate(-50%, -50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 24%;
}
50% {
--reveal: 76%;
}
92%, 100% {
--reveal: 24%;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
}
}(function () {
var el = document.getElementById("ba");
var dragging = false;
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
el.style.setProperty("--reveal", pct + "%");
}
function fromX(x) {
var r = el.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
el.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
el.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
el.addEventListener("pointermove", function (e) { if (dragging) set(fromX(e.clientX)); });
el.addEventListener("pointerup", function () { dragging = false; });
})();import { useRef, useState, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BeforeAfterCaptionOverlay.module.scss";
export type BeforeAfterCaptionOverlayProps = {
duration?: string;
easing?: string;
color?: string;
caption?: string;
};
type Vars = CSSProperties & { "--reveal"?: string };
/** 하단 그러데이션 띠 위에 캡션 글자가 얹혀, 무엇을 비교하는 사진인지 말로 설명한다. */
export default function BeforeAfterCaptionOverlay({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
caption = "3주 경과",
}: BeforeAfterCaptionOverlayProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [reveal, setReveal] = useState(50);
const [demo, setDemo] = useState(true);
const dragging = useRef(false);
const setFromX = useCallback((x: number) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
setReveal(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}, []);
const onDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDemo(false);
dragging.current = true;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
setFromX(e.clientX);
}, [setFromX]);
const onMove = useCallback((e: PointerEvent<HTMLDivElement>) => { if (dragging.current) setFromX(e.clientX); }, [setFromX]);
const onUp = useCallback(() => { dragging.current = false; }, []);
const vars: Vars = { "--reveal": `${reveal}%`, ["--duration" as string]: duration, ["--easing" as string]: easing, ["--color" as string]: color };
return (
<div
ref={ref}
className={demo ? `${styles.ba} ${styles.isDemo}` : styles.ba}
style={vars}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
>
<div className={styles.after} />
<div className={styles.before} />
<div className={styles.caption}>{caption}</div>
<div className={styles.handle}>
<span className={styles.grip} />
</div>
</div>
);
}07 · 캡션 오버레이 비교 슬라이더 — 측정 누적면적 11.721% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.ba__caption {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: $sp-6 $sp-4 $sp-3;
background: linear-gradient(to top, rgba(23,20,26,.78) 0%, rgba(23,20,26,0) 100%);
color: $color;
font: 700 13px/1.3 system-ui, sans-serif;
}
08줌 동기화 비교 슬라이더
화면에서 일어나는 일
슬라이더 위에서 휠을 굴리면 전·후 두 장이 같은 배율·같은 중심점으로 함께 확대돼, 세부를 확대해서 봐도 비교가 어긋나지 않습니다.

<main class="stage">
<div class="ba is-demo" id="ba">
<div class="ba__frame">
<div class="ba__after"></div>
<div class="ba__before"></div>
</div>
<div class="ba__handle"><span class="ba__grip"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --zoom {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@include stage($stage-paper);
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
touch-action: none;
cursor: ew-resize;
--reveal: 50%;
--zoom: 1;
}
.ba__frame {
position: absolute;
inset: 0;
overflow: hidden;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
transform: scale(var(--zoom));
transform-origin: center;
}
.ba__after { background: linear-gradient(155deg, $subject-cream 0%, $color 100%); }
.ba__before {
background: linear-gradient(155deg, $subject-ink 0%, $color 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: $sp-1;
transform: translateX(-50%);
background: $subject-cream;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
background: $subject-cream;
transform: translate(-50%, -50%);
box-shadow: $shadow-press;
}
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes reveal-loop {
0%, 8% { --reveal: 30%; --zoom: 1; }
50% { --reveal: 70%; --zoom: 1.35; }
92%, 100% { --reveal: 30%; --zoom: 1; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; --zoom: 1; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --zoom {
syntax: "<number>";
inherits: true;
initial-value: 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);
}
.ba {
position: relative;
width: 200px;
max-width: 100%;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: ew-resize;
--reveal: 50%;
--zoom: 1;
}
.ba__frame {
position: absolute;
inset: 0;
overflow: hidden;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
transform: scale(var(--zoom));
transform-origin: center;
}
.ba__after {
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
}
.ba__before {
background: linear-gradient(155deg, #17141a 0%, #2f6df6 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba__handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--reveal);
width: 4px;
transform: translateX(-50%);
background: #fff7e6;
}
.ba__grip {
position: absolute;
top: 50%;
left: 50%;
width: 32px;
height: 32px;
border-radius: 999px;
background: #fff7e6;
transform: translate(-50%, -50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 30%;
--zoom: 1;
}
50% {
--reveal: 70%;
--zoom: 1.35;
}
92%, 100% {
--reveal: 30%;
--zoom: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
--zoom: 1;
}
}(function () {
var el = document.getElementById("ba");
var dragging = false;
function setReveal(pct) {
pct = Math.max(0, Math.min(100, pct));
el.style.setProperty("--reveal", pct + "%");
}
function setZoom(z) {
z = Math.max(1, Math.min(2.2, z));
el.style.setProperty("--zoom", z.toFixed(2));
}
function fromX(x) {
var r = el.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
el.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
el.setPointerCapture(e.pointerId);
setReveal(fromX(e.clientX));
});
el.addEventListener("pointermove", function (e) { if (dragging) setReveal(fromX(e.clientX)); });
el.addEventListener("pointerup", function () { dragging = false; });
el.addEventListener("wheel", function (e) {
el.classList.remove("is-demo");
e.preventDefault();
var cur = parseFloat(getComputedStyle(el).getPropertyValue("--zoom")) || 1;
setZoom(cur + (e.deltaY < 0 ? 0.15 : -0.15));
}, { passive: false });
})();import { useRef, useState, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BeforeAfterZoomSync.module.scss";
export type BeforeAfterZoomSyncProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--reveal"?: string; "--zoom"?: string };
type WheelEv = { deltaY: number; preventDefault: () => void; [k: string]: any };
/** 휠을 굴리면 전·후 두 장이 같은 배율·중심점으로 함께 확대돼 세부 비교가 어긋나지 않는다. */
export default function BeforeAfterZoomSync({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BeforeAfterZoomSyncProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [reveal, setReveal] = useState(50);
const [zoom, setZoom] = useState(1);
const [demo, setDemo] = useState(true);
const dragging = useRef(false);
const setFromX = useCallback((x: number) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
setReveal(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}, []);
const onDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDemo(false);
dragging.current = true;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
setFromX(e.clientX);
}, [setFromX]);
const onMove = useCallback((e: PointerEvent<HTMLDivElement>) => { if (dragging.current) setFromX(e.clientX); }, [setFromX]);
const onUp = useCallback(() => { dragging.current = false; }, []);
const onWheel = useCallback((e: WheelEv) => {
setDemo(false);
e.preventDefault();
setZoom((z) => Math.max(1, Math.min(2.2, z + (e.deltaY < 0 ? 0.15 : -0.15))));
}, []);
const vars: Vars = {
"--reveal": `${reveal}%`,
"--zoom": zoom.toFixed(2),
["--duration" as string]: duration,
["--easing" as string]: easing,
["--color" as string]: color,
};
return (
<div
ref={ref}
className={demo ? `${styles.ba} ${styles.isDemo}` : styles.ba}
style={vars}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onWheel={onWheel}
>
<div className={styles.frame}>
<div className={styles.after} />
<div className={styles.before} />
</div>
<div className={styles.handle}>
<span className={styles.grip} />
</div>
</div>
);
}08 · 줌 동기화 비교 슬라이더 — 측정 누적면적 15.303% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
el.addEventListener("wheel", function (e) {
el.classList.remove("is-demo");
e.preventDefault();
var cur = parseFloat(getComputedStyle(el).getPropertyValue("--zoom")) || 1;
setZoom(cur + (e.deltaY < 0 ? 0.15 : -0.15));
}, { passive: false });
09여러 장 비교 캐러셀
어떤 움직임인가
scroll-snap 캐러셀 안에 전후 슬라이더 세 쌍을 나란히 두고, IntersectionObserver로 지금 화면 중앙에 온 슬라이드만 드래그가 켜집니다.

<main class="stage">
<div class="track" id="track" aria-label="전후 비교 캐러셀">
<div class="ba is-demo is-active" data-i="1">
<div class="ba__after"></div>
<div class="ba__before"></div>
</div>
<div class="ba is-demo" data-i="2">
<div class="ba__after"></div>
<div class="ba__before"></div>
</div>
<div class="ba is-demo" data-i="3">
<div class="ba__after"></div>
<div class="ba__before"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-ink);
.track {
display: flex;
gap: $sp-3;
width: 260px;
max-width: 100%;
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: $r-card;
}
.ba {
position: relative;
flex: 0 0 200px;
aspect-ratio: 4 / 3;
scroll-snap-align: center;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-press;
touch-action: pan-x;
--reveal: 50%;
}
.ba.is-active { box-shadow: $shadow-raised, 0 0 0 $sp-1 $subject-cream; cursor: ew-resize; }
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after { background: linear-gradient(155deg, $color 0%, $subject-cream 100%); }
.ba__before {
background: linear-gradient(155deg, $stage-ink 0%, $color 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba.is-demo { animation: reveal-loop $dur-loop $easing infinite; }
.ba.is-demo[data-i="2"] { animation-delay: -0.6s; }
.ba.is-demo[data-i="3"] { animation-delay: -1.2s; }
@keyframes reveal-loop {
0%, 8% { --reveal: 20%; }
50% { --reveal: 80%; }
92%, 100% { --reveal: 20%; }
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo { animation: none; --reveal: 50%; }
}@property --reveal {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.track {
display: flex;
gap: 12px;
width: 260px;
max-width: 100%;
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: 16px;
}
.ba {
position: relative;
flex: 0 0 200px;
aspect-ratio: 4/3;
scroll-snap-align: center;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
touch-action: pan-x;
--reveal: 50%;
}
.ba.is-active {
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), 0 0 0 4px #fff7e6;
cursor: ew-resize;
}
.ba__after,
.ba__before {
position: absolute;
inset: 0;
}
.ba__after {
background: linear-gradient(155deg, #2f6df6 0%, #fff7e6 100%);
}
.ba__before {
background: linear-gradient(155deg, #17141a 0%, #2f6df6 70%);
clip-path: inset(0 calc(100% - var(--reveal)) 0 0);
}
.ba.is-demo {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ba.is-demo[data-i="2"] {
animation-delay: -0.6s;
}
.ba.is-demo[data-i="3"] {
animation-delay: -1.2s;
}
@keyframes reveal-loop {
0%, 8% {
--reveal: 20%;
}
50% {
--reveal: 80%;
}
92%, 100% {
--reveal: 20%;
}
}
@media (prefers-reduced-motion: reduce) {
.ba.is-demo {
animation: none;
--reveal: 50%;
}
}(function () {
var track = document.getElementById("track");
var panes = Array.prototype.slice.call(track.querySelectorAll(".ba"));
panes.forEach(function (pane) {
var dragging = false;
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
pane.style.setProperty("--reveal", pct + "%");
}
function fromX(x) {
var r = pane.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
pane.addEventListener("pointerdown", function (e) {
if (!pane.classList.contains("is-active")) return;
pane.classList.remove("is-demo");
dragging = true;
pane.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
pane.addEventListener("pointermove", function (e) { if (dragging) set(fromX(e.clientX)); });
pane.addEventListener("pointerup", function () { dragging = false; });
});
if ("IntersectionObserver" in window) {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
en.target.classList.toggle("is-active", en.intersectionRatio > 0.6);
});
}, { root: track, threshold: [0.6] });
panes.forEach(function (p) { io.observe(p); });
}
})();import { useRef, useState, useEffect, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BeforeAfterMultiCarousel.module.scss";
export type BeforeAfterMultiCarouselProps = {
duration?: string;
easing?: string;
color?: string;
pairs?: number;
};
type Vars = CSSProperties & { "--reveal"?: string };
/** 전후 슬라이더 여러 쌍을 scroll-snap 캐러셀에 나란히 두고, IntersectionObserver 로 화면 중앙에 온 것만 드래그를 켠다. */
export default function BeforeAfterMultiCarousel({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
pairs = 3,
}: BeforeAfterMultiCarouselProps) {
const trackRef = useRef<HTMLDivElement | null>(null);
const paneRefs = useRef<(HTMLDivElement | null)[]>([]);
const [reveals, setReveals] = useState<number[]>(() => Array(pairs).fill(50));
const [demoIdx, setDemoIdx] = useState<Set<number>>(() => new Set(Array.from({ length: pairs }, (_, i) => i)));
const [activeIdx, setActiveIdx] = useState(0);
const dragging = useRef<number | null>(null);
useEffect(() => {
const track = trackRef.current;
if (!track || !("IntersectionObserver" in window)) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((en) => {
if (en.intersectionRatio > 0.6) {
const i = (paneRefs.current || []).indexOf(en.target as HTMLDivElement);
if (i >= 0) setActiveIdx(i);
}
});
},
{ root: track, threshold: [0.6] }
);
(paneRefs.current || []).forEach((p) => p && io.observe(p));
return () => io.disconnect();
}, [pairs]);
const setFromX = useCallback((i: number, x: number) => {
const el = (paneRefs.current || [])[i];
if (!el) return;
const r = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100));
setReveals((arr) => arr.map((v, idx) => (idx === i ? pct : v)));
}, []);
const onDown = useCallback((i: number) => (e: PointerEvent<HTMLDivElement>) => {
if (activeIdx !== i) return;
setDemoIdx((s) => { const n = new Set(s); n.delete(i); return n; });
dragging.current = i;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
setFromX(i, e.clientX);
}, [activeIdx, setFromX]);
const onMove = useCallback((i: number) => (e: PointerEvent<HTMLDivElement>) => {
if (dragging.current === i) setFromX(i, e.clientX);
}, [setFromX]);
const onUp = useCallback(() => { dragging.current = null; }, []);
return (
<div className={styles.track} ref={trackRef}>
{reveals.map((reveal, i) => {
const vars: Vars = { "--reveal": `${reveal}%`, ["--duration" as string]: duration, ["--easing" as string]: easing, ["--color" as string]: color };
const isDemo = demoIdx.has(i);
const isActive = activeIdx === i;
const cls = [styles.ba, isDemo ? styles.isDemo : "", isActive ? styles.isActive : ""].filter(Boolean).join(" ");
return (
<div
key={i}
ref={(el: HTMLDivElement | null) => { if (paneRefs.current) paneRefs.current[i] = el; }}
className={cls}
style={vars}
onPointerDown={onDown(i)}
onPointerMove={onMove(i)}
onPointerUp={onUp}
>
<div className={styles.after} />
<div className={styles.before} />
</div>
);
})}
</div>
);
}09 · 여러 장 비교 캐러셀 — 측정 누적면적 13.271% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
en.target.classList.toggle("is-active", en.intersectionRatio > 0.6);
});
}, { root: track, threshold: [0.6] });
panes.forEach(function (p) { io.observe(p); });
어디서 깨지는가 — 오래된 브라우저는 부드럽게 안 밀린다
아홉 개 전부 --reveal이라는 커스텀 프로퍼티 하나로 경계선 위치를 관리합니다. 이 값을 @property로 <percentage> 타입으로 등록해야 자동 미리보기의 경계선이 설정한 구간 안에서 부드럽게 밀립니다 — 등록 없이 키프레임에 커스텀 프로퍼티를 쓰면 브라우저가 값을 보간하지 못해 뚝뚝 끊기며 점프합니다. 다행히 실제 드래그·클릭 조작은 JS가 인라인 스타일로 값을 직접 써 넣기 때문에 이 등록을 지원하지 않는 아주 오래된 브라우저에서도 사용자가 손으로 조작하는 데는 문제가 없습니다. 만드는 동안 세로형(05번) 하나는 480×300 무대에선 멀쩡했는데 320px 폭 확인에서 실제로 높이가 넘쳐 카드 폭을 200px에서 120px로 줄여야 했습니다 — 가로 슬라이더보다 세로 슬라이더가 좁은 화면에서 더 쉽게 넘칩니다. 이 끌기 감각을 손가락으로 옮긴 목록은 모바일 터치 제스처 9종에서 다룹니다. 아홉 파일의 압축 비밀번호는 dv4hytn4이고, zip을 열면 vanilla·react 버전이 항목마다 같이 들어 있습니다.
접근성 — reduced-motion에서 아홉 개가 남기는 것
prefers-reduced-motion: reduce를 걸면 아홉 개 모두 자동 미리보기 애니메이션(is-demo 루프)이 멈추고 경계선이 50% 고정값에 머뭅니다. 04번은 원래도 키보드만으로 전 구간을 조작할 수 있어 애니메이션이 꺼져도 기능이 줄지 않습니다. 08번의 줌도 같은 규칙으로 정지 상태에선 배율이 1로 고정됩니다.
FAQ
이미지 비교 슬라이더는 꼭 JS가 있어야 하나요?
네. 경계선이 클릭·드래그·키보드 같은 실시간 입력을 따라가야 해서 --reveal 값을 계속 갱신하는 JS 코드가 필요합니다. 아홉 개 전부 12~34줄 사이의 짧은 바닐라 JS만 씁니다.
이미지 갤러리 css 처럼 내 사진은 어떻게 넣나요?
.ba__after·.ba__before의 background 자리에 url(경로)로 실제 사진을 넣거나, <img> 태그로 바꿔 object-fit: cover를 주면 됩니다. zip 안 README에 넣는 법이 적혀 있습니다.
세로형과 가로형을 한 페이지에 같이 써도 되나요?
됩니다. --reveal 커스텀 프로퍼티와 clip-path 축만 다를 뿐 서로 클래스 이름이 겹치지 않게 스코프만 나누면 같은 페이지 안에서 함께 씁니다. 행을 옆으로 밀어 지우는 리스트 쪽도 같은 칸 축을 공유하는데, 스와이프 삭제 리스트 UI가 그 대표다.
전후 비교의 clip-path 원리는 MDN clip-path 문서에, 포인터 캡처는 MDN Pointer events 문서에 정리돼 있습니다. 이미지를 겹쳐 보여주는 다른 방식은 이미지 갤러리 CSS 9종에서, 실전 UI 모음은 실전 UI 카테고리에서 더 볼 수 있습니다.









