First CH Designs
BLK-59Blocks

clip-pathワイプrevealフォトグリッド

写真主導のサイトに見られる「フェードではなく覆いが引かれて絵が出る」出現を、実績・事例のフォトグリッドとして再構築したブロック。出現は一度きりのトリガーではなくスクロール量に連動し、戻せばマスクも閉じる可逆・連続の動きで、行ごとに開く向きを左から・下から・右からと反転させ、行内はさらにスタッガーする。JSがするのはrAFで合体させた1本のハンドラがセクションへ --p(0→1)を書くことだけで、各タイルは clamp((--p - --s) / --w, 0, 1) で自分のローカル進捗をCSSのcalcだけで算出する(依存ライブラリなし・フレーム毎のsetStateなし)。マスクの中の画は閉じ側へ寄せた位置から戻るので、動いているのは覆いだけに見える。写真はCSSグラデーションと図形だけで組んだ画像素材ゼロのカットで、列比は行ごとに変えて等間隔の格子に見せない。情報は番号・見出し・業種・年だけに絞り、抜粋もタグチップも持たない。キーボードで到達したタイルは :focus-within で必ず開き切り、reduced-motion時はCSSで --p: 1 !important を当てて「最初から開き切っている」に畳む。

追加日:
2026-09-13
依存:
なし
tags
#works #case-study #gallery #photo-grid #clip-path #mask-reveal #wipe #scroll-linked #stagger #css-gradient #no-image #no-dependency

プレビュー

BLK-59実績・事例撮り下ろし

7

スクロールしたぶんだけ現場が開いていく

写真はうっすら浮かび上がるのではなく、覆いが引かれるように現れます。行ごとに開く向きを変え、読み進める速さと現れる速さをそろえました。戻れば覆いも戻ります。

20252026撮り下ろし 7

実績の一覧を見る
"use client";

import { useEffect, useRef, type CSSProperties } from "react";

/**
 * clip-pathワイプreveal フォトグリッド(BLK-59)
 *
 * 差別化メモ(既存の実績・事例は「並べ方」で分かれている: 等間隔グリッド / 不揃いグリッド /
 * 横送りカルーセル / 3Dティルト / WebGL巡回)。本ブロックだけは**並びではなく出現そのものが主題**で、
 * 一度きりのトリガーではなくスクロール量に連動してマスクが開き**戻すと閉じる(可逆・連続)**。
 * 行ごとに開く向きが反転し、情報は番号・見出し・業種・年のみで抜粋もタグチップも持たない。
 * 節の定型も意図的に外した: 英字キッカーなし / 角丸ゼロ(全て角のある面)/ 末尾の矢印リンクなし。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **フェードでなくマスクを開く**: 出現を opacity で作ると「薄い絵が濃くなる」だけだが、
 *   clip-path: inset() を 100% から 0 へ縮めると「覆いが引かれて絵が出る」写真的な出現になる。
 *   マスクは矩形1枚なので paint の範囲が限定され、タイル数を抑えれば実用域に収まる
 *   (transform/opacity と違い合成だけでは済まない点は自覚して使う)。
 * - **内側を逆へ寄せて「動いていない写真」に見せる**: マスクだけ動かすと絵まで流れて見えるので、
 *   中の画(.cwg-shot)を閉じ側へ translate しておき 0 へ戻す。周囲 12% ぶん大きく敷いて
 *   ずらしても地が出ないようにするのが要点。
 * - **進捗は要素ごとに自分で測る**: JS は rAF で合体した1ハンドラが各 .cwg-t へ --p(0 から 1)を
 *   書くだけ。節に1本の進捗で全部を駆動すると、縦に長い節では下の行が画面へ入る前に開き切る
 *   (節の高さと各行の到達時刻が比例しないため)。分母に要素自身の高さを混ぜておくと、
 *   一覧の縮小プレビューのように小さく描画されたときも最後まで到達する。
 *   行内のスタッガーだけは --t: clamp(0, (--p - --s) / --w, 1) としてCSSのcalcに任せる
 *   (インラインの --s を渡すだけなので、タイルを増やしてもJSの分岐が増えない)。
 * - **閉じたマスクにフォーカスを入れない**: .cwg-t:focus-within { --t: 1 } を stylesheet 側に置くと
 *   (:focus-within のほうが詳細度が高いので)キーボードで到達したタイルだけ必ず開き切る。
 *   フォーカスリングが切られないよう、clip-path をかけるのは <a> の内側の要素だけにする。
 * - **reduced-motion はJSでなくCSSで畳む**: @media で --p: 1 !important を当てるとJSが書く
 *   インラインスタイルより強いので「最初から開き切っている」が唯一の状態になる(JSもリスナーを張らない)。
 */

type Work = {
  no: string;
  title: string;
  field: string;
  year: string;
  /** CSSグラデーションだけで組んだ画(shots の index)。画像素材は使わない */
  shot: number;
  aspect: string;
};

const works: Work[] = [
  { no: "01", title: "朝の外観から撮り直す", field: "医療", year: "2026", shot: 0, aspect: "aspect-[16/10]" },
  { no: "02", title: "焙煎の熱を色で残す", field: "飲食", year: "2025", shot: 2, aspect: "aspect-[4/5]" },
  { no: "03", title: "縦格子と影の反復", field: "建築", year: "2026", shot: 1, aspect: "aspect-[3/4]" },
  { no: "04", title: "机上に並んだ試作", field: "製造", year: "2025", shot: 3, aspect: "aspect-[3/4]" },
  { no: "05", title: "夜の工場を見上げる", field: "製造", year: "2026", shot: 5, aspect: "aspect-[3/4]" },
  { no: "06", title: "海へ開くテラス", field: "宿泊", year: "2026", shot: 4, aspect: "aspect-[4/5]" },
  { no: "07", title: "広場を真上から", field: "公共", year: "2025", shot: 6, aspect: "aspect-[16/10]" },
];

/**
 * 行。dir が開く向き(l=左から / d=上から / r=右から)。
 * 列比を行ごとに変え、等間隔の格子に見せない。
 */
const rows: { dir: "l" | "d" | "r"; cols: string; items: number[] }[] = [
  // 縦横比の違うカットが並ぶ行は sm:items-end。背の低いほうを下端で揃え、
  // 余った空きを上へ逃がすと「穴」ではなく段差の律動になる。
  { dir: "l", cols: "sm:grid-cols-[1.32fr_1fr] sm:items-end", items: [0, 1] },
  { dir: "d", cols: "sm:grid-cols-3", items: [2, 3, 4] },
  { dir: "r", cols: "sm:grid-cols-[1fr_1.32fr] sm:items-end", items: [5, 6] },
];

/** 画像素材の代替。1枚の画=重ねた絶対配置レイヤ(すべてCSSグラデーションと図形) */
const shots: CSSProperties[][] = [
  // 00 朝の外観
  [
    { inset: 0, background: "linear-gradient(180deg,#fffbf5 0%,#fdf3e3 44%,#e7e5e4 44%,#d6d3d1 100%)" },
    {
      inset: 0,
      background:
        "radial-gradient(circle at 72% 26%, rgba(217,119,6,0.9) 0 5%, rgba(217,119,6,0.22) 5% 11%, transparent 11%)",
    },
    {
      left: "8%",
      right: "46%",
      top: "30%",
      bottom: 0,
      background: "#292524",
      clipPath: "polygon(0 22%, 62% 0, 100% 14%, 100% 100%, 0 100%)",
    },
    {
      left: "52%",
      right: "10%",
      top: "46%",
      bottom: 0,
      background: "#57534e",
      clipPath: "polygon(0 18%, 100% 0, 100% 100%, 0 100%)",
    },
  ],
  // 01 縦格子
  [
    { inset: 0, background: "repeating-linear-gradient(90deg,#e7e5e4 0 7px,#fafaf9 7px 17px)" },
    { inset: 0, background: "linear-gradient(158deg, rgba(28,25,23,0.62) 0 34%, rgba(28,25,23,0) 62%)" },
    { left: 0, right: 0, top: "63%", height: "8%", background: "rgba(217,119,6,0.85)" },
  ],
  // 02 手元
  [
    { inset: 0, background: "linear-gradient(180deg,#fef3c7 0%,#fde68a 38%,#fafaf9 100%)" },
    { inset: 0, background: "radial-gradient(120% 86% at 50% 116%, #1c1917 0 40%, rgba(28,25,23,0) 41%)" },
    {
      left: "26%",
      top: "32%",
      width: "24%",
      aspectRatio: "1",
      background: "radial-gradient(circle, #d97706 0 44%, rgba(217,119,6,0.25) 44% 70%, transparent 70%)",
    },
  ],
  // 03 机上
  [
    { inset: 0, background: "linear-gradient(112deg,#fafaf9 0 38%,#f5f5f4 38% 66%,#e7e5e4 66% 100%)" },
    { left: "14%", top: "17%", width: "34%", height: "29%", background: "#292524" },
    { left: "54%", top: "39%", width: "26%", height: "43%", background: "rgba(217,119,6,0.9)" },
    { left: "14%", top: "58%", width: "30%", height: "7%", background: "#a8a29e" },
  ],
  // 04 海へ開くテラス
  [
    { inset: 0, background: "linear-gradient(180deg,#fef7e7 0 30%,#fcd34d 30% 33%,#8c8279 33% 100%)" },
    {
      left: 0,
      right: 0,
      top: "33%",
      bottom: 0,
      background: "repeating-linear-gradient(180deg, rgba(255,255,255,0.26) 0 1px, rgba(255,255,255,0) 1px 13px)",
    },
    {
      left: 0,
      right: 0,
      top: "33%",
      bottom: 0,
      background: "linear-gradient(180deg, rgba(28,25,23,0) 0, rgba(28,25,23,0.5) 100%)",
    },
    {
      left: "-5%",
      right: "-5%",
      bottom: 0,
      height: "26%",
      background: "#292524",
      clipPath: "polygon(0 44%, 100% 12%, 100% 100%, 0 100%)",
    },
  ],
  // 05 夜の工場
  [
    { inset: 0, background: "linear-gradient(196deg,#1c1917 0%,#3f3a36 66%,#57534e 100%)" },
    {
      left: 0,
      right: 0,
      top: 0,
      height: "62%",
      background: "radial-gradient(circle, rgba(245,158,11,0.8) 0 1px, rgba(245,158,11,0) 1.1px) 0 0/23px 23px",
    },
    {
      left: "-4%",
      right: "38%",
      top: "30%",
      bottom: 0,
      background: "#0c0a09",
      clipPath: "polygon(0 28%, 100% 0, 100% 100%, 0 100%)",
    },
    {
      left: "44%",
      right: "-4%",
      top: "58%",
      bottom: 0,
      background: "#12100f",
      clipPath: "polygon(0 22%, 100% 6%, 100% 100%, 0 100%)",
    },
  ],
  // 06 広場を真上から
  [
    {
      inset: 0,
      background:
        "conic-gradient(from 208deg at 42% 34%, #f5f5f4 0 24%, #e7e5e4 24% 48%, #fafaf9 48% 74%, #d6d3d1 74% 100%)",
    },
    { left: "-10%", right: "-10%", top: "48%", height: "6%", background: "rgba(217,119,6,0.85)", transform: "rotate(-8deg)" },
    {
      left: "58%",
      top: "12%",
      width: "18%",
      aspectRatio: "1",
      background: "radial-gradient(circle, #292524 0 46%, transparent 46%)",
    },
  ],
];

/** 全カット共通の粒子。マスクの内側だが画とは一緒に動かさない(動いているのは覆いだけに見せる) */
const grain: CSSProperties = {
  inset: 0,
  background: "repeating-linear-gradient(45deg, rgba(28,25,23,0.05) 0 1px, rgba(28,25,23,0) 1px 6px)",
};

const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);

/** インラインで渡す行内スタッガーの開始位置(0〜1のローカル進捗上の座標) */
const at = (s: number) => ({ "--s": s.toFixed(2) }) as CSSProperties;

function Shot({ v }: { v: number }) {
  return (
    <>
      {shots[v].map((layer, i) => (
        <span key={i} aria-hidden="true" className="absolute" style={layer} />
      ))}
    </>
  );
}

export default function ClipWipeGallery() {
  const rootRef = useRef<HTMLElement>(null);

  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;

    // reduced-motion: 開き切った状態を CSS 側(--p: 1 !important)が固定するので、
    // JS はリスナーを一切張らない。
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    let rafId = 0;
    let visible = true;

    // 進捗は「行・タイルそれぞれが自分の位置で測る」。セクション1本の進捗で全部を駆動すると、
    // 縦に長い節では下の行が画面へ入る前に開き切ってしまう(節の高さと行の到達時刻が比例しない)。
    const nodes = Array.from(el.querySelectorAll<HTMLElement>(".cwg-t"));

    const update = () => {
      rafId = 0;
      const vh = window.innerHeight || 1;
      for (const n of nodes) {
        const rect = n.getBoundingClientRect();
        // 上端がビューポート88%を切ってから min(自分の高さ, 70vh) ぶん進む間で 0 から 1 へ。
        // 分母に要素自身の高さを混ぜてあるので、一覧の縮小プレビューのように
        // 小さく描画されたときも最後まで到達する。
        const span = Math.max(1, Math.min(rect.height, vh * 0.7));
        n.style.setProperty("--p", clamp01((vh * 0.88 - rect.top) / span).toFixed(4));
      }
    };

    // rAF で1フレーム1回に合体させる(scroll は passive)
    const onScroll = () => {
      if (!visible || rafId) return;
      rafId = requestAnimationFrame(update);
    };

    // 画面外では計測そのものを止める(出入りの瞬間には必ず1回そろえる)
    const io =
      typeof IntersectionObserver !== "undefined"
        ? new IntersectionObserver(
            (es) => {
              visible = es.some((e) => e.isIntersecting);
              update();
            },
            { rootMargin: "35% 0px" },
          )
        : null;
    io?.observe(el);

    update();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll, { passive: true });
    return () => {
      io?.disconnect();
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      if (rafId) cancelAnimationFrame(rafId);
    };
  }, []);

  return (
    <section
      ref={rootRef}
      aria-labelledby="cwg-heading"
      className="cwg w-full max-w-6xl bg-white px-1"
    >
      {/* 台帳の罫: 英字キッカーを置かず、標本番号と点数だけの1行にする */}
      <div className="flex items-baseline justify-between gap-4 border-b border-gray-200 pb-3">
        <p className="font-display text-[11px] font-semibold tracking-wide text-gray-500">
          BLK-59
          <span aria-hidden="true" className="mx-2 text-gray-300">
            /
          </span>
          <span className="font-sans font-bold text-gray-700">実績・事例</span>
          <span aria-hidden="true" className="mx-2 text-gray-300">
            /
          </span>
          <span className="font-sans font-medium text-gray-400">撮り下ろし</span>
        </p>
        <p className="font-display text-[11px] font-semibold tabular-nums text-gray-400">
          {works.length} 点
        </p>
      </div>

      {/* 見出しは動かさない。この節で動くのは覆いだけ、という構えを崩さないため */}
      <header className="mt-8 max-w-2xl">
        <h2
          id="cwg-heading"
          className="text-[clamp(1.7rem,4vw,2.5rem)] leading-[1.32] font-bold tracking-tight text-gray-900"
        >
          スクロールした
          <span className="relative whitespace-nowrap">
            ぶんだけ
            <span aria-hidden="true" className="absolute -bottom-1 left-0 h-[3px] w-full bg-amber-500/70" />
          </span>
          、<span className="whitespace-nowrap">現場が開いていく</span>
        </h2>
        {/* 和文の段落はソース上でも改行しない(折ると「、 読」のように句読点の後だけ空く) */}
        <p className="mt-6 text-[15px] leading-[1.95] font-medium text-gray-600">
          写真はうっすら浮かび上がるのではなく、覆いが引かれるように現れます。行ごとに開く向きを変え、読み進める速さと現れる速さをそろえました。戻れば覆いも戻ります。
        </p>
      </header>

      {rows.map((row, r) => (
        <div key={r} className={`cwg-t cwg-${row.dir} mt-14 sm:mt-16`}>
          {/* 行の罫線は開く向きへ引かれる(transform: scaleX のみ) */}
          <span aria-hidden="true" className="cwg-rule block h-px bg-gray-300" />

          <ul className={`mt-7 grid list-none grid-cols-1 gap-x-6 gap-y-12 ${row.cols}`}>
            {row.items.map((wi, i) => {
              const w = works[wi];
              return (
                <li key={w.no} className="cwg-t" style={at(i * 0.1)}>
                  {/* clip-path をかけるのは <a> の内側だけ。フォーカスリングが切られないようにする */}
                  <a
                    href="#"
                    className="group block focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
                  >
                    <div className={`relative overflow-hidden bg-stone-100 ${w.aspect}`}>
                      <span className="cwg-mask absolute inset-0 block">
                        {/* 画は周囲12%ぶん大きく敷き、閉じ側へ寄せた位置から0へ戻す */}
                        <span className="cwg-shot absolute -inset-[12%] block">
                          <Shot v={w.shot} />
                        </span>
                        <span aria-hidden="true" className="absolute" style={grain} />
                      </span>
                    </div>

                    <div className="relative mt-4 pt-4">
                      <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-gray-200" />
                      <span
                        aria-hidden="true"
                        className="absolute inset-x-0 top-0 h-px origin-left scale-x-0 bg-amber-600 transition-transform duration-500 ease-out group-hover:scale-x-100 motion-reduce:transition-none"
                      />
                      {/* キャプションは画と逆向きに開く */}
                      <div className="cwg-cap">
                        <div className="flex items-baseline gap-3">
                          <span className="font-display text-[11px] font-bold tabular-nums text-amber-700">
                            {w.no}
                          </span>
                          <h3 className="text-[15px] leading-[1.6] font-bold tracking-tight text-gray-900">
                            {w.title}
                          </h3>
                        </div>
                        <p className="mt-2 text-[11px] font-medium text-gray-400">
                          {w.field}
                          <span aria-hidden="true" className="mx-1.5 text-gray-300">
                            /
                          </span>
                          <span className="font-display tabular-nums">{w.year}</span>
                        </p>
                      </div>
                    </div>
                  </a>
                </li>
              );
            })}
          </ul>
        </div>
      ))}

      <div className="mt-16 flex flex-wrap items-baseline justify-between gap-4 border-t border-gray-200 pt-6">
        <p className="font-display text-[11px] font-semibold tabular-nums text-gray-400">
          2025
          <span aria-hidden="true" className="mx-1.5 text-gray-300">
            —
          </span>
          2026
          <span aria-hidden="true" className="mx-2 text-gray-300">
            /
          </span>
          <span className="font-sans font-medium">撮り下ろし {works.length} 点</span>
        </p>
        <a
          href="#"
          className="group inline-flex items-center text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
        >
          <span className="relative">
            実績の一覧を見る
            <span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
            <span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
          </span>
        </a>
      </div>

      {/* スクロール進捗からローカル進捗、そしてマスクへの換算はすべてここ(JSは --p を書くだけ) */}
      <style>{`
        .cwg { --w: 0.75; }
        .cwg-t { --p: 0; --t: clamp(0, (var(--p) - var(--s, 0)) / var(--w), 1); }
        /* キーボードで到達したタイルは必ず開き切る(:focus-within のほうが詳細度が高い) */
        .cwg-t:focus-within { --t: 1; }

        .cwg-mask, .cwg-cap { will-change: clip-path; }
        .cwg-l .cwg-mask { clip-path: inset(0 calc((1 - var(--t)) * 100%) 0 0); }
        .cwg-r .cwg-mask { clip-path: inset(0 0 0 calc((1 - var(--t)) * 100%)); }
        .cwg-d .cwg-mask { clip-path: inset(0 0 calc((1 - var(--t)) * 100%) 0); }

        .cwg-shot { will-change: transform; }
        .cwg-l .cwg-shot { transform: translateX(calc((1 - var(--t)) * 9%)); }
        .cwg-r .cwg-shot { transform: translateX(calc((1 - var(--t)) * -9%)); }
        .cwg-d .cwg-shot { transform: translateY(calc((1 - var(--t)) * 9%)); }

        .cwg-l .cwg-cap { clip-path: inset(0 0 0 calc((1 - var(--t)) * 100%)); }
        .cwg-r .cwg-cap { clip-path: inset(0 calc((1 - var(--t)) * 100%) 0 0); }
        .cwg-d .cwg-cap { clip-path: inset(0 calc((1 - var(--t)) * 100%) 0 0); }

        .cwg-rule { transform: scaleX(var(--t)); }
        .cwg-l .cwg-rule { transform-origin: left center; }
        .cwg-r .cwg-rule { transform-origin: right center; }
        .cwg-d .cwg-rule { transform-origin: center center; }

        @media (prefers-reduced-motion: reduce) {
          .cwg-t { --p: 1 !important; }
        }
      `}</style>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/clip-wipe-gallery.json