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