改修前後の比較スライダー
同じ座標に「改修前」と「改修後」の誌面を2枚重ね、境界をドラッグして見比べるブロック。上層は幅ではなく clip-path で削るため、動かしても中身が再レイアウトされず文字の折り返し位置が1pxも変わらない(幅を変える実装では折り返しが動いて「同じ紙面の比較」に見えなくなる)。境界位置は pointermove ごとに CSS カスタムプロパティへ直書きし、React state は rAF で間引いて読み上げ用の数値だけに使う=指と境界の間に再レンダ分の遅れが出ない。inline style の初期値を state 由来にしないのが要点で、定数で1度だけ書けば以後 React がその属性に触らず直書きが生き残る。setPointerCapture で枠外へ出ても追従し、touch-action は pan-y にして狭幅の縦スクロールを殺さない。BEFOREバッジは上層のDOMに入れて境界が通れば一緒に切れるので、出し分けの状態を持たない。つまみは role="slider" で ←→ 2%・PageUp/Down 10%・Home/End で端まで。誌面は2枚ともDOMで組んであり画像素材ゼロ・依存ゼロ。初回だけ境界が1往復して「掴める」ことを伝え、prefers-reduced-motion では最初から動かさない。2026-08-24。
- 追加日:
- 2026-08-24
- 依存:
- なし
- tags
- #block #compare #slider #drag #clip-path #pointer-events #custom-property #a11y #no-image #no-dependency
プレビュー
BLK-38 — Compare / 比較
改修の前後
境界を左右にドラッグすると、同じ紙面の改修前と改修後が入れ替わります。掲載している誌面はすべて架空のサンプルです。
Renewal
必要な情報に、
まっすぐ届く。
読む順番を決めてから組み直した誌面。
お問い合わせお客様第一主義で
幅広く事業を展開しております
当社はお客様のニーズにお応えするため、日々努力を重ねてまいりました。詳しくは各ページをご覧ください。最新情報は随時更新しております。
>> 詳しくはこちら <<
項目 1
ここに説明文が入ります
項目 2
ここに説明文が入ります
項目 3
ここに説明文が入ります
つまみは← →キーでも動かせます。
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* リニューアル前後の比較スライダー(BLK-38)
*
* 同じ座標に「改修前」と「改修後」の誌面を2枚重ね、境界をドラッグして見比べるブロック。
* 2枚とも画像ではなくDOM(罫線・帯・文字)で組んであるので、画像素材ゼロ・依存ゼロで動く。
* 掲載しているテキストはすべて架空のオリジナル。
*
* 汎用技法メモ(web-design-playbook 還流用):
*
* 1. 上に重ねる面は width ではなく clip-path で削る
* 上層の幅を変えて見せ隠しすると、その都度中身が再レイアウトされ、文字の折り返し位置が
* 動いて「同じ紙面の比較」に見えなくなる。`clip-path: inset(0 calc(100% - var(--pos)) 0 0)`
* なら描画だけを切るのでレイアウトは一切動かない(合成のみでスクロールも走らない)。
*
* 2. 追従する値は state ではなく CSS カスタムプロパティへ直書きする
* pointermove ごとに setState すると再レンダが挟まり、指と境界の間に1フレーム分の遅れが出る。
* 境界位置は `stage.style.setProperty("--cmp-pos", ...)` で直接書き、React state は
* rAF で間引いて「読み上げ用の数値表示」だけに使う。
* **要点: inline style に書く初期値は state から作らない**。state 由来にすると再レンダのたびに
* 1フレーム古い値で上書きされて境界がガタつく。定数(50%)で1度だけ書けば React は以後
* その属性を触らないので、直書きした値が生き残る。
*
* 3. 掴んだ指はステージに固定する(setPointerCapture)
* 枠外へ出ても pointermove が届き続けるので「掴んだまま外に出ると止まる」が消える。
* 縦スクロールは殺したくないので `touch-action: pan-y`(Tailwind の touch-pan-y)にする。
* none にすると横ドラッグは快適になるが、狭幅でブロックの上を縦に流せなくなる。
*
* 4. ラベルは上層の中に置いて一緒に切らせる
* 「BEFORE」バッジの表示/非表示を state で出し分ける必要はない。上層のDOMに入れておけば
* 境界が通り過ぎた瞬間に clip で消える=状態を1つ減らせる。
*
* 5. ドラッグ可能であることは最初の1回だけ動いて伝える
* 画面に入ったら境界を 50%→64%→38%→50% と1往復させる(rAF・1.5s・easeInOutCubic)。
* ユーザーが触った時点で中断する。prefers-reduced-motion では最初から動かさない。
*
* 6. つまみは role="slider"(ドラッグ専用UIにしない)
* ←→ で2%、PageUp/Down で10%、Home/End で端まで。aria-valuenow と aria-valuetext を
* 両方持たせ、%だけでなく「どちらの面が見えているか」を読み上げさせる。
*/
const INITIAL_POS = 50;
/** 改修後(自社ブランド)の誌面 — 白基調・アンバー・階層のある組み */
function AfterFace() {
return (
<div className="flex h-full w-full flex-col bg-white">
<div className="flex items-center gap-2 border-b border-gray-200 px-[4.5%] py-[2.6%]">
<span aria-hidden="true" className="h-2.5 w-2.5 shrink-0 bg-amber-600" />
<span
className={`${montserrat.className} text-[clamp(7px,1.35cqw,12px)] font-bold tracking-[0.24em] text-gray-900 uppercase`}
>
Corporate
</span>
<span className="ml-auto flex items-center gap-[3cqw]">
<span className="text-[clamp(6px,1.1cqw,10px)] font-semibold text-gray-500">
事業案内
</span>
<span className="text-[clamp(6px,1.1cqw,10px)] font-semibold text-gray-500">実績</span>
<span className="hidden items-center border-b border-amber-600 pb-px text-[clamp(6px,1.1cqw,10px)] font-semibold text-amber-700 @sm:inline-flex">
お問い合わせ
</span>
</span>
</div>
{/* 左右どちらを切り出しても紙面として成立するよう、主(見出し)と従(番号リスト)を両側に置く */}
<div className="grid flex-1 grid-cols-[1.15fr_1fr] items-center">
<div className="px-[4.5%] py-[3%]">
<p
className={`${montserrat.className} text-[clamp(6px,1.05cqw,10px)] font-bold tracking-[0.28em] text-amber-600 uppercase`}
>
Renewal
</p>
<p className="mt-[1.6cqw] text-[clamp(13px,3.2cqw,28px)] leading-[1.32] font-bold tracking-tight text-gray-900">
必要な情報に、
<br />
まっすぐ届く。
</p>
<p className="mt-[1.8cqw] text-[clamp(6px,1.15cqw,11px)] leading-[1.9] text-gray-600">
読む順番を決めてから組み直した誌面。
</p>
<span className="mt-[2.4cqw] inline-flex w-fit items-center gap-[1.4cqw] bg-amber-600 px-[2.6cqw] py-[1.2cqw] text-[clamp(6px,1.1cqw,10px)] font-bold text-white">
お問い合わせ
<span aria-hidden="true">→</span>
</span>
</div>
<div className="border-l border-gray-200 px-[6%] py-[3%]">
{["制作", "運用", "改善"].map((label, index) => (
<div
key={label}
className={`flex items-baseline gap-[2cqw] py-[1.6cqw] ${
index > 0 ? "border-t border-gray-200" : ""
}`}
>
<span
className={`${montserrat.className} text-[clamp(6px,1cqw,9px)] font-bold text-amber-600 tabular-nums`}
>
{String(index + 1).padStart(2, "0")}
</span>
<span className="text-[clamp(6px,1.15cqw,11px)] font-bold text-gray-900">
{label}
</span>
</div>
))}
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 px-[4.5%] py-[2%]">
<span className="text-[clamp(5px,0.95cqw,9px)] text-gray-400">© Sample</span>
<span aria-hidden="true" className="h-px w-[18%] bg-amber-600" />
</div>
</div>
);
}
/** 改修前 — 全中央寄せ・カード羅列・面だらけの「よくある古い誌面」 */
function BeforeFace() {
return (
<div className="flex h-full w-full flex-col bg-gray-100">
<div className="flex items-center justify-center border-b-2 border-gray-300 bg-gray-200 py-[2.2%]">
<span className="text-[clamp(7px,1.3cqw,12px)] font-bold tracking-[0.1em] text-gray-500">
*** 会社案内サイト ***
</span>
</div>
<div className="flex flex-1 flex-col items-center justify-center px-[4%] py-[2.5%] text-center">
<p className="text-[clamp(8px,1.9cqw,17px)] leading-[1.5] font-bold text-gray-600">
お客様第一主義で
<br />
幅広く事業を展開しております
</p>
<p className="mt-[1.4cqw] max-w-[86%] text-[clamp(5px,0.95cqw,9px)] leading-[1.7] text-gray-500">
当社はお客様のニーズにお応えするため、日々努力を重ねてまいりました。詳しくは各ページをご覧ください。最新情報は随時更新しております。
</p>
<p className="mt-[1.4cqw] text-[clamp(5px,0.95cqw,9px)] text-blue-700 underline">
>> 詳しくはこちら <<
</p>
<div className="mt-[2cqw] grid w-full grid-cols-3 gap-[1.6cqw]">
{[0, 1, 2].map((index) => (
<div
key={index}
className="border-2 border-gray-300 bg-white px-[1.6cqw] py-[1.8cqw] shadow-[3px_3px_0_rgba(0,0,0,0.08)]"
>
<div className="mx-auto h-[3.4cqw] w-[3.4cqw] rounded-full bg-gray-200" />
<p className="mt-[1cqw] text-[clamp(5px,0.9cqw,9px)] font-bold text-gray-500">
項目 {index + 1}
</p>
<p className="mt-[0.6cqw] text-[clamp(4px,0.8cqw,8px)] leading-[1.5] text-gray-400">
ここに説明文が入ります
</p>
</div>
))}
</div>
</div>
<div className="border-t-2 border-gray-300 bg-gray-200 py-[1.8%] text-center text-[clamp(5px,0.9cqw,9px)] text-gray-500">
Copyright All Rights Reserved.
</div>
<span
className={`${montserrat.className} absolute bottom-[3.2cqw] left-[4.5%] border border-gray-400 bg-white/90 px-[1.4cqw] py-[0.7cqw] text-[clamp(6px,1cqw,9px)] font-bold tracking-[0.22em] text-gray-500 uppercase`}
>
Before
</span>
</div>
);
}
export default function BeforeAfterCompare() {
const uid = useId();
const rootRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const posRef = useRef(INITIAL_POS);
const rafRef = useRef(0);
const hintRafRef = useRef(0);
const touchedRef = useRef(false);
const [pos, setPos] = useState(INITIAL_POS);
const [dragging, setDragging] = useState(false);
/** 境界はCSS変数へ直書き。state は読み上げ・数値表示用に rAF で間引いて追随させる */
const applyPos = useCallback((next: number) => {
const clamped = Math.min(100, Math.max(0, next));
posRef.current = clamped;
stageRef.current?.style.setProperty("--cmp-pos", `${clamped}%`);
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
setPos(Math.round(posRef.current));
});
}, []);
const stopHint = useCallback(() => {
touchedRef.current = true;
if (hintRafRef.current) {
cancelAnimationFrame(hintRafRef.current);
hintRafRef.current = 0;
}
}, []);
const posFromClientX = useCallback((clientX: number) => {
const rect = stageRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return INITIAL_POS;
return ((clientX - rect.left) / rect.width) * 100;
}, []);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
stopHint();
stageRef.current?.setPointerCapture(event.pointerId);
setDragging(true);
applyPos(posFromClientX(event.clientX));
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
applyPos(posFromClientX(event.clientX));
};
const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
setDragging(false);
if (stageRef.current?.hasPointerCapture(event.pointerId)) {
stageRef.current.releasePointerCapture(event.pointerId);
}
};
const onHandleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
const current = posRef.current;
let next: number | null = null;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = current - 2;
else if (event.key === "ArrowRight" || event.key === "ArrowUp") next = current + 2;
else if (event.key === "PageDown") next = current - 10;
else if (event.key === "PageUp") next = current + 10;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = 100;
if (next === null) return;
event.preventDefault();
stopHint();
applyPos(next);
};
// 初回だけ「掴める」ことを1往復で伝える(reduced-motion では動かさない)
useEffect(() => {
const root = rootRef.current;
if (!root) return;
if (typeof window === "undefined") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (typeof IntersectionObserver === "undefined") return;
const start = () => {
if (touchedRef.current) return;
const from = INITIAL_POS;
const keys = [50, 64, 38, 50];
const duration = 1500;
const t0 = performance.now();
const step = (now: number) => {
if (touchedRef.current) return;
const t = Math.min(1, (now - t0) / duration);
const seg = Math.min(keys.length - 2, Math.floor(t * (keys.length - 1)));
const local = t * (keys.length - 1) - seg;
const eased = local < 0.5 ? 4 * local ** 3 : 1 - (-2 * local + 2) ** 3 / 2;
applyPos(keys[seg] + (keys[seg + 1] - keys[seg]) * eased);
if (t < 1) hintRafRef.current = requestAnimationFrame(step);
else {
hintRafRef.current = 0;
applyPos(from);
}
};
hintRafRef.current = requestAnimationFrame(step);
};
const io = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
io.disconnect();
window.setTimeout(start, 450);
}
},
{ threshold: 0.4 },
);
io.observe(root);
return () => io.disconnect();
}, [applyPos]);
useEffect(
() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (hintRafRef.current) cancelAnimationFrame(hintRafRef.current);
},
[],
);
const presetClass =
"rounded-full border px-3 py-1 text-[11.5px] font-semibold tracking-[0.04em] transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none";
return (
<section
ref={rootRef}
aria-labelledby={`${uid}-title`}
className="@container w-full max-w-4xl bg-white px-1 text-gray-900"
>
<header className="grid grid-cols-1 gap-x-10 gap-y-4 @2xl:grid-cols-12">
<div className="@2xl:col-span-7">
<p
className={`${montserrat.className} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase`}
>
BLK-38 — Compare / 比較
</p>
<h2
id={`${uid}-title`}
className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-[clamp(1.6rem,3.4vw,2.25rem)] leading-[1.3] font-bold tracking-tight"
>
<span className="whitespace-nowrap">改修の前後</span>
<span
className={`${montserrat.className} text-[12px] font-semibold tracking-[0.22em] text-gray-300 uppercase`}
aria-hidden="true"
>
Before / After
</span>
</h2>
</div>
<p className="text-[13.5px] leading-[1.95] font-medium text-gray-600 @2xl:col-span-5 @2xl:self-end">
境界を左右にドラッグすると、同じ紙面の改修前と改修後が入れ替わります。掲載している誌面はすべて架空のサンプルです。
</p>
</header>
<div className="mt-8 border-t border-gray-200 pt-7 @2xl:mt-10">
<div
ref={stageRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={`@container relative aspect-[4/3] w-full touch-pan-y overflow-hidden border border-gray-200 select-none @md:aspect-[16/10] ${
dragging ? "cursor-grabbing" : "cursor-grab"
}`}
/* 初期値は定数で1度だけ書く。state 由来にすると再レンダで古い値に戻る */
style={{ "--cmp-pos": `${INITIAL_POS}%` } as React.CSSProperties}
>
{/* 下層: 改修後(常に全面。境界より右がそのまま見える) */}
<div className="absolute inset-0">
<AfterFace />
</div>
{/* 上層: 改修前。幅ではなく clip で削るのでレイアウトは動かない */}
<div
className="absolute inset-0"
style={{ clipPath: "inset(0 calc(100% - var(--cmp-pos)) 0 0)" }}
>
<BeforeFace />
</div>
{/* 改修後側のラベル(上層に切られない位置=右端に置く) */}
<span
aria-hidden="true"
className={`${montserrat.className} pointer-events-none absolute bottom-[3.2cqw] right-[4.5%] bg-amber-600 px-[1.4cqw] py-[0.7cqw] text-[clamp(6px,1cqw,9px)] font-bold tracking-[0.22em] text-white uppercase`}
>
After
</span>
{/* 境界の罫 */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-px -translate-x-1/2 bg-amber-600"
style={{ left: "var(--cmp-pos)" }}
/>
{/* つまみ(role="slider") */}
<button
type="button"
role="slider"
aria-label="改修前と改修後の境界位置"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pos}
aria-valuetext={`改修前 ${pos}%・改修後 ${100 - pos}%`}
aria-orientation="horizontal"
onKeyDown={onHandleKeyDown}
onPointerDown={(event) => event.stopPropagation()}
className="absolute top-1/2 flex h-10 w-10 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full border border-amber-600 bg-white text-amber-700 shadow-[0_2px_10px_rgba(0,0,0,0.12)] transition-transform duration-200 ease-out hover:scale-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none motion-reduce:hover:scale-100"
style={{ left: "var(--cmp-pos)" }}
>
<span aria-hidden="true" className="text-[13px] leading-none font-bold">
‹›
</span>
</button>
</div>
{/* 操作帯 */}
<div className="mt-5 flex flex-col gap-4 @2xl:flex-row @2xl:items-center @2xl:gap-8">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase">
View
</span>
{[
{ label: "改修前", value: 100 },
{ label: "半々", value: 50 },
{ label: "改修後", value: 0 },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
stopHint();
applyPos(preset.value);
}}
className={`${presetClass} ${
pos === preset.value
? "border-amber-600 bg-amber-600 text-white"
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-900"
}`}
>
{preset.label}
</button>
))}
</div>
<p className="text-[12.5px] leading-[1.9] text-gray-500 @2xl:ml-auto">
つまみは
<span className="mx-1 font-mono text-[11px] text-amber-700">← →</span>
キーでも動かせます。
</p>
</div>
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-gray-200 bg-gray-50/70 px-4 py-3 font-mono text-[11px] text-gray-500">
<span className="whitespace-nowrap">
before <span className="text-amber-700">{pos}%</span>
</span>
<span className="whitespace-nowrap">
after <span className="text-amber-700">{100 - pos}%</span>
</span>
<span className="whitespace-nowrap">
clip <span className="text-amber-700">inset(0 calc(100% - --cmp-pos) 0 0)</span>
</span>
</div>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/before-after-compare.json