9 Before After Slider UIs — Drag to Compare
A before after slider stacks two photos and splits them at a line you can move, so before and after show side by side.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Click-to-reveal comparison slider
- 02 Drag-handle comparison slider
- 03 Touch-swipe comparison slider
- 04 Keyboard-accessible comparison slider
- 05 Vertical comparison slider
- 06 Before/After label badges
- 07 Caption-overlay comparison slider
- 08 Zoom-synced comparison slider
- 09 Multi-pair comparison carousel
These nine follow the order the component actually gets used in, not a "prettiest first" list: click once to see it move, the way any before after slider online demo starts, drag it continuously, try it on a phone, check it with only a keyboard, flip the axis to vertical, add labels, add a caption, zoom in, then finally line several pairs up on one page. Hanging many photos on a wall instead of stacking two is 9 CSS Image Gallery Grid Effects, and handling their order by mouse is Portfolio Gallery HTML CSS.
01Click-to-reveal comparison slider
What a before after slider ui shows on first click
Click anywhere on the photo and the divider jumps straight to that x-position.

<main class="stage">
<div class="ba is-demo" id="ba" role="img" aria-label="Click to move the divider there, comparing before and after">
<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 · Click-to-reveal comparison slider — Measured area 12.083% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It's the simplest version to try first, since there's no drag involved at all.
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)) + "%");
});
02Drag-handle comparison slider
What happens on screen
Drag the round handle back and forth and it follows the pointer in real time.

<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 · Drag-handle comparison slider — Measured area 13.55% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
This is the version most product pages actually ship.
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));
});
03Touch-swipe comparison slider
What moves
The whole slider is the touch target, not just a thin handle, and the grip grows to 48px so a fingertip lands on it reliably.

<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 · Touch-swipe comparison slider — Measured area 12.826% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.ba {
touch-action: none;
cursor: grab;
}
.ba__grip {
width: $sp-12; // 48px — a realistic minimum touch target
height: $sp-12;
border-radius: $r-pill;
}
04Keyboard-accessible comparison slider
What you see
Tab onto the slider and arrow keys move it 5% at a time; Home and End jump to either end.

<main class="stage">
<div class="ba is-demo" id="ba"
role="slider" tabindex="0"
aria-label="Before/after comparison slider"
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 · Keyboard-accessible comparison slider — Measured area 13.651% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
No mouse required to compare before and after.
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(); }
});
05Vertical comparison slider
What happens on screen
The divider moves up and down instead of left and right, so a tall vertical photo doesn't need to be rotated to compare.

<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 · Vertical comparison slider — Measured area 8.635% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.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 label badges
What moves
Fixed pill badges in the top corners fade in and out as the divider passes, so it's never ambiguous which side is which.

<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 label badges — Measured area 13.842% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
// one property for the position, one plain 0-1 number for opacity math
el.style.setProperty("--reveal", pct + "%");
el.style.setProperty("--reveal-num", (pct / 100).toFixed(3));
}
07Caption-overlay comparison slider
What you see
A translucent gradient band sits along the bottom with a caption like "3 weeks later," spelling out in words what the photos compare.

<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 weeks later</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 · Caption-overlay comparison slider — Measured area 11.721% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
.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;
}
08Zoom-synced comparison slider
What happens on screen
Scrolling the wheel zooms both photos together at the same scale and center point, so a close look at fine detail never drifts out of sync.

<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 · Zoom-synced comparison slider — Measured area 15.303% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
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 });
09Multi-pair comparison carousel
What moves
Three sliders sit side by side in a scroll-snap carousel, and IntersectionObserver enables dragging only on whichever pane is centered in view.

<main class="stage">
<div class="track" id="track" aria-label="Before/after comparison carousel">
<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 · Multi-pair comparison carousel — Measured area 13.271% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
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); });
Where does it break — old browsers don't glide
All nine manage the divider through one custom property, --reveal. Registering it with @property as a <percentage> is what lets each auto-preview loop glide smoothly between its low and high point (roughly 20–30% up to 70–80%, depending on the item) — without that registration, a keyframe animation on a custom property can't be interpolated at all, so the browser jumps between values instead of tweening. The good news is that real dragging and clicking write the value straight into an inline style from JS, so a browser that doesn't support this registration still lets a person operate the slider by hand just fine. While building this set, the vertical one (item 05) looked fine at the 480×300 stage but actually overflowed vertically once checked at a 320px-wide viewport, so its card width had to shrink from 200px to 120px — a vertical slider runs out of height on a narrow screen faster than a horizontal one runs out of width. Carrying that drag feel over to fingers on glass is what 9 Mobile Touch Gestures covers. The zip's password is dv4hytn4, and every folder ships both a vanilla and a React version side by side.
Accessibility — what reduced-motion leaves behind
With prefers-reduced-motion: reduce, all nine stop their auto-preview loop (the is-demo animation) and settle at a fixed 50%. Item 04 already lets someone move the whole range with just a keyboard, so turning the animation off costs it nothing. Item 08's zoom follows the same rule and locks its scale to 1 when motion is reduced.
FAQ
Does a before/after slider need JavaScript?
Yes. The divider has to track live input from a click, drag, or key press, which means something has to keep updating --reveal. All nine use between 12 and 34 lines of plain vanilla JS to do it.
How do I swap real photos into this before after slider html?
Replace the background on .ba__after and .ba__before with url(path), or switch the div to an <img> with object-fit: cover. The zip's README spells out both options.
Can I mix vertical and horizontal sliders on one page?
Yes — they only differ in which axis --reveal and clip-path move along, so as long as class names don't collide, both can live on the same page. Lists that wipe a row away sideways share the same horizontal axis — 9 Swipe to Delete List UI Patterns is that family's representative.
The clip-path mechanics are documented on MDN's clip-path page, and pointer capture on MDN's Pointer events page. For a different way to lay out multiple photos, see 9 CSS Image Gallery Grid Effects, and browse more real-world UI in the UI category.









