ビフォーアフター スライダー UI 9選 — ドラッグで比較
ビフォーアフター スライダーは2枚の写真を重ね、動かせる境界線で前後を並べて見せる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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 クリック比較スライダー
- 02 ドラッグハンドル比較スライダー
- 03 タッチスワイプ比較スライダー
- 04 キーボード対応比較スライダー
- 05 縦方向比較スライダー
- 06 BEFORE・AFTERラベルバッジ
- 07 キャプションオーバーレイ比較スライダー
- 08 ズーム同期比較スライダー
- 09 複数ペア比較カルーセル
9つは見た目の順番ではなく、このスライダーが実際に使われる順番で並べました。css 画像 ホバーの感覚でまずクリックで動かしてみて → 指でずっと引っぱってみて → スマホで押してみて → キーボードだけで操作できるか確認して → 向きを縦に変えてみて → ラベルを付けてみて → キャプションを添えてみて → 拡大してみて → 最後に複数組を1ページに並べます。重ねた2枚の代わりに写真を壁に並べる側がCSS フォト ギャラリー 9選で、並びをマウスで扱う側がポートフォリオ ギャラリー UI 9選です。
01クリック比較スライダー
ビフォーアフター 画像 スライダーで何が見えるか
写真のどこをクリックしても、その位置に境界線がすぐ移動します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ドラッグ不要の最も単純な形なので、まず試すのに向いています。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
多くの商品ページが採用する基本形です。
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タッチスワイプ比較スライダー
どんな動きか
細いハンドルだけでなくスライダー全体がタッチ対象になり、グリップも48pxまで広げているので指先が太くても確実に当たります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
マウスがなくても前後を比較できます。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
.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ズーム同期比較スライダー
画面で起きること
スライダー上でホイールを回すと前後2枚が同じ倍率・同じ中心点で一緒に拡大され、細部を拡大しても比較がずれません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
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カルーセルの中に前後スライダー3組を並べ、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
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); });
どこで壊れるか — 古いブラウザは滑らかに動かない
9つとも --reveal というカスタムプロパティ一つで境界線の位置を管理しています。この値を @property で <percentage> 型として登録して初めて、自動プレビューの境界線が20%から80%まで滑らかに動きます — 登録しないままキーフレームでカスタムプロパティを動かすと、ブラウザが値を補間できずカクカクと飛びます。幸い実際のドラッグ・クリック操作はJSがインラインスタイルに値を直接書き込むため、この登録に対応しない古いブラウザでも手での操作自体は問題なく動きます。制作中、縦型(05番)は480×300の舞台では問題なかったのに、320px幅で確認すると実際に高さがはみ出し、カード幅を200pxから120pxまで縮める必要がありました — 横型より縦型のほうが、狭い画面で先にあふれます。この320px確認は9項目すべてに毎回行っていて、はみ出しがあれば数値でそのまま分かるので見た目だけで判断しません。この引きの感覚を指に移した一覧はスマホ ジェスチャー操作 9選で扱っています。9ファイルの圧縮パスワードはdv4hytn4で、zipにはどの項目もvanilla版とReact版が並んで入っています。
アクセシビリティ — reduced-motionで9つが残すもの
prefers-reduced-motion: reduce を指定すると、9つとも自動プレビューのループ(is-demoアニメーション)が止まり、境界線は50%で固定されます。04番はもともとキーボードだけで全域を操作できるため、アニメーションを止めても機能は減りません。08番のズームも同じ規則で、動きを抑えると倍率が1に固定されます。
FAQ
ビフォーアフター スライダーにはJavaScriptが必須ですか?
はい。境界線はクリック・ドラッグ・キー入力といったリアルタイムの入力に追従する必要があり、--reveal を更新し続けるコードが要ります。9つとも12〜34行の短いバニラJSだけで書いています。
css フォト ギャラリーのように写真はどう差し替えますか?
.ba__after・.ba__before の background に url(パス) を指定するか、<img> タグに変えて object-fit: cover を指定します。zip内のREADMEに両方の方法を書いています。
縦型と横型を同じページに混在させても大丈夫ですか?
大丈夫です。--reveal と clip-path の軸が違うだけなので、クラス名さえ衝突しなければ同じページに共存できます。行を横に押して消すリストも同じ横軸を共有しており、スワイプ削除リストUI 9選がその代表です。
clip-pathの仕組みはMDNのclip-pathページに、ポインターキャプチャはMDNのPointer eventsページにまとまっています。複数の写真を並べる別の方法はCSS フォト ギャラリー 9選で、実用UIはUIカテゴリーでさらに見られます。









