First CH Designs
BLK-59Blocks

Works — Clip-path Wipe Reveal Photo Grid

A works grid that borrows the photo-led idea of revealing an image by pulling a cover away rather than fading it in. The reveal is not a one-shot trigger: it is scrubbed by scroll position and closes again when you scroll back, with each row opening from a different edge (left, bottom, right) and tiles staggering within the row. JS only writes a single --p (0 to 1) on the section from one rAF-merged handler; every tile derives its own local progress in pure CSS via clamp((--p - --s) / --w, 0, 1) — no dependencies, no per-frame setState. The artwork inside each mask starts offset toward the closing edge and settles at 0, so only the cover appears to move. Images are pure CSS gradients and shapes (zero assets), column ratios differ per row to avoid a uniform lattice, and each caption carries only an index, a headline, a sector and a year. Tiles that receive keyboard focus open fully via :focus-within, and prefers-reduced-motion pins --p to 1 so the fully open state is the only state.

Added:
2026-09-13
Dependencies:
None
tags
#works #case-study #gallery #photo-grid #clip-path #mask-reveal #wipe #scroll-linked #stagger #css-gradient #no-image #no-dependency

Preview

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>
  );
}

Add to your project via shadcn CLI

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