First CH Designs
BLK-38Blocks

改修前後の比較スライダー

同じ座標に「改修前」と「改修後」の誌面を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 / 比較

改修の前後

境界を左右にドラッグすると、同じ紙面の改修前と改修後が入れ替わります。掲載している誌面はすべて架空のサンプルです。

Corporate事業案内実績

Renewal

必要な情報に、
まっすぐ届く。

読む順番を決めてから組み直した誌面。

お問い合わせ
01制作
02運用
03改善
© Sample
*** 会社案内サイト ***

お客様第一主義で
幅広く事業を展開しております

当社はお客様のニーズにお応えするため、日々努力を重ねてまいりました。詳しくは各ページをご覧ください。最新情報は随時更新しております。

>> 詳しくはこちら <<

項目 1

ここに説明文が入ります

項目 2

ここに説明文が入ります

項目 3

ここに説明文が入ります

Copyright All Rights Reserved.
Before
View

つまみは← →キーでも動かせます。

before 50%after 50%clip inset(0 calc(100% - --cmp-pos) 0 0)
"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">
          &gt;&gt; 詳しくはこちら &lt;&lt;
        </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