BLK-20Blocks
3Dティルト・ホバーカード表現
hairline罫線のレジャー(台帳)に並べた実績のうち、カーソルを重ねた1件だけがマウスに追従して傾き、台紙から起き上がる実績ブロック。追従値は React の state ではなく CSS 変数(--tx/--ty/--px/--py)へ直接書き込むため再レンダーはゼロ、rect は pointerenter で1回だけ測りスクロール時のみ測り直す。回転・層ごとの視差(translateZ)・光沢の移動はすべて transform と opacity だけで表現し、追従90ms/離脱650msの時定数も CSS 変数で切り替える。ライブラリ依存ゼロ。精密ポインタ(hover:hover and pointer:fine)かつ prefers-reduced-motion でない環境でのみ配線し、キーボード操作にはティルトの代わりに group-focus-within の罫線と矢印で状態を返す。マウス追従の3Dティルトと層の視差という着想を、コード・文言・配色・構成すべてFirst CHのオリジナルとして再構築した(外部コード・素材の取り込みなし・2026-08-11)。
- 追加日:
- 2026-08-11
- 依存:
- なし
- tags
- #block #works #case-study #hover #3d #tilt #parallax #motion #no-image #no-dependency
プレビュー
Specimen BLK-20
これまでにお手伝いした仕事。
業種も規模も違う4件を、同じ物差しで並べました。カーソルを重ねた1件だけが台紙から起き上がります。
Selected Works — 2025 / 2026
実績をすべて見る"use client";
import { useCallback, useEffect, useRef } from "react";
/**
* 3Dティルト・ホバーカード表現(BLK-20)
*
* 汎用技法メモ(web-design-playbook へ還流する要点)
*
* 1. 「ポインタ → CSS変数」で再レンダーをゼロにする
* 追従値を React の state に入れると 60fps ぶんの再レンダーが走る。ref で掴んだ DOM に
* `style.setProperty("--tx", …)` を直接書き、CSS 側は `calc(var(--tx) * 7deg)` で読む。
* JS が書くのは数値4つ(--tx / --ty=中心からの偏差 -1〜1、--px / --py=要素内座標)だけで、
* 回転・視差・光沢の位置はすべて CSS が導出する。React のツリーは一切更新されない。
*
* 2. rect は pointerenter で1回だけ測る(+スクロール時に測り直す)
* pointermove ごとの getBoundingClientRect は強制同期レイアウトでジャンクの主因。
* hover 中は要素の位置が変わらない前提で入場時にキャッシュし、唯一の例外である
* スクロールでだけ passive リスナで測り直す。
*
* 3. 書き込みは requestAnimationFrame で1フレーム1回に集約
* pointermove は1フレームに複数回飛ぶ。最新の座標だけ ref に溜め、rAF が空いていなければ
* 予約しない(`if (!raf.current)`)ことで、書き込み回数を描画回数に一致させる。
*
* 4. 追従は「速い」、離脱は「ゆっくり」— duration も CSS 変数で切り替える
* 追従中に長い transition を掛けると遅延して見え、0 にすると離脱時にカクッと戻る。
* `duration-[var(--tilt-ms,650ms)]` としておき、enter で 90ms / leave で 650ms に差し替える。
* クラスの付け外し(=再レンダー)なしで2つの時定数を両立できる。
*
* 5. preserve-3d と overflow:hidden は共存できない
* overflow が visible 以外の要素は transform-style が強制的に flat になり、子の translateZ が
* 潰れる。そこで「クリップしたい層(光沢・ゴースト数字)」だけを overflow-hidden の別レイヤに
* 切り出し、視差を付けたい層は plate の直下(preserve-3d のまま)に置く。
* 奥行きの重なり順は DOM 順ではなく z 位置で決まるので、z を持つ本文が自動的に手前に来る。
*
* 6. 触覚のないデバイスと reduced-motion では「配線しない」
* `(hover: hover) and (pointer: fine)` かつ reduced-motion でないときだけ変数を書く。
* 変数が未設定なら transform は恒等変換に畳まれるので、フォールバックの分岐を書かずに済む。
* キーボード利用者にはティルトの代わりに group-focus-within の罫線+矢印で状態を伝える。
*
* 着想: 「マウス追従の3Dティルトと、層ごとの視差ホバー」という着想。
* 実装・文言・配色・構成はすべて First CH のオリジナル(外部コード・素材の取り込みは無し)。
*/
type Work = {
no: string;
monogram: string;
client: string;
category: string;
title: string;
excerpt: string;
services: string;
year: string;
};
const works: Work[] = [
{
no: "01",
monogram: "HS",
client: "Hikari Sangyo",
category: "製造",
title: "図面の問い合わせを、フォームで受け止める",
excerpt:
"型番と用途を先に聞く入力欄に組み替え、折り返しの電話を一往復減らしました。担当者が朝いちばんに見る画面から設計しています。",
services: "コーポレート / 問い合わせ設計",
year: "2026",
},
{
no: "02",
monogram: "NR",
client: "Nagi Residence",
category: "不動産",
title: "内見までの導線を、一画面にまとめる",
excerpt:
"間取り・周辺環境・空室状況を縦に積まず横に並べ、比較したまま予約に進める構成へ。物件が増えても崩れない型を残しました。",
services: "ブランドサイト / 予約",
year: "2026",
},
{
no: "03",
monogram: "TS",
client: "Tsuchi to Sora",
category: "農業・EC",
title: "収穫の周期に合わせて、売り場を組み替える",
excerpt:
"旬の入れ替わりを毎月の更新作業にせず、期間を入れておけば自動で入れ替わる棚に。産地の言葉をそのまま載せられる編集画面を用意しました。",
services: "EC / 運用設計",
year: "2025",
},
{
no: "04",
monogram: "KM",
client: "Kokage Music",
category: "教室",
title: "体験レッスンの申込を、迷わせない",
excerpt:
"コースの違いを料金表の一行差ではなく、通う人の生活時間で説明。申込前の質問をFAQへ集約し、問い合わせの半分を自己解決に変えました。",
services: "コーポレート / 集客",
year: "2025",
},
];
export default function WorkTiltLedger() {
const raf = useRef(0);
const active = useRef<{ el: HTMLElement; rect: DOMRect; x: number; y: number } | null>(null);
const enabled = useRef(false);
useEffect(() => {
// ポインタが精密(マウス)で、かつ reduced-motion でないときだけティルトを配線する。
enabled.current =
window.matchMedia("(hover: hover) and (pointer: fine)").matches &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// hover 中に位置が変わる唯一のケース=スクロール。ここでだけ rect を測り直す。
const onScroll = () => {
if (active.current) active.current.rect = active.current.el.getBoundingClientRect();
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf.current);
};
}, []);
// 1フレームに1回だけ CSS 変数へ書き出す(React の state は経由しない)
const flush = useCallback(() => {
raf.current = 0;
const a = active.current;
if (!a) return;
const px = a.x - a.rect.left;
const py = a.y - a.rect.top;
a.el.style.setProperty("--px", `${px.toFixed(1)}px`);
a.el.style.setProperty("--py", `${py.toFixed(1)}px`);
a.el.style.setProperty("--tx", ((px / a.rect.width) * 2 - 1).toFixed(3));
a.el.style.setProperty("--ty", ((py / a.rect.height) * 2 - 1).toFixed(3));
}, []);
const onEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!enabled.current) return;
const el = e.currentTarget;
el.style.setProperty("--tilt-ms", "90ms"); // 追従は速く
el.style.setProperty("--lift", "16px");
active.current = { el, rect: el.getBoundingClientRect(), x: e.clientX, y: e.clientY };
if (!raf.current) raf.current = requestAnimationFrame(flush);
},
[flush],
);
const onMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const a = active.current;
if (!enabled.current || !a || a.el !== e.currentTarget) return;
a.x = e.clientX;
a.y = e.clientY;
if (!raf.current) raf.current = requestAnimationFrame(flush);
},
[flush],
);
const onLeave = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const el = e.currentTarget;
if (active.current?.el === el) active.current = null;
el.style.setProperty("--tilt-ms", "650ms"); // 戻りはゆっくり
el.style.setProperty("--lift", "0px");
el.style.setProperty("--tx", "0");
el.style.setProperty("--ty", "0");
}, []);
return (
<section className="@container w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
{/* 見出し */}
<div className="px-6 pt-8 @2xl:px-10 @2xl:pt-10">
<p className="text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase">
Specimen BLK-20
</p>
<h2 className="font-display mt-3 text-[clamp(1.5rem,3.4vw,2rem)] leading-tight font-bold tracking-tight text-gray-900">
これまでにお手伝いした仕事。
</h2>
<p className="mt-3 max-w-lg text-sm leading-[1.9] text-gray-600">
業種も規模も違う4件を、同じ物差しで並べました。カーソルを重ねた1件だけが台紙から起き上がります。
</p>
</div>
{/* 台紙=hairline罫線のレジャー。gap-px の下地(bg-gray-200)が罫線そのものになる */}
<div className="mt-8 grid gap-px border border-gray-200 bg-gray-200 [perspective-origin:50%_50%] @2xl:grid-cols-2 mx-6 @2xl:mx-10">
{works.map((w) => (
<article
key={w.no}
className="group relative bg-white [perspective:1000px] focus-within:z-10 hover:z-10"
>
{/* plate=実際に傾く板。overflow は付けない(preserve-3d が flat に潰れるため) */}
<div
onPointerEnter={onEnter}
onPointerMove={onMove}
onPointerLeave={onLeave}
className="relative h-full bg-white transition-transform duration-[var(--tilt-ms,650ms)] ease-out [transform-style:preserve-3d] [transform:translate3d(0,0,var(--lift,0px))_rotateX(calc(var(--ty,0)*-5deg))_rotateY(calc(var(--tx,0)*7deg))] will-change-transform motion-reduce:transition-none motion-reduce:[transform:none]"
>
{/* 影は別レイヤの opacity で出し入れする(box-shadow 自体はアニメーションさせない) */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-0 shadow-[0_26px_50px_-30px_rgba(15,23,42,0.55)] transition-opacity duration-300 group-hover:opacity-100 motion-reduce:transition-none"
/>
{/* クリップしたいものだけを閉じ込める層(ここは flat でよい) */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
{/* 光沢: ポインタ座標に置くだけ。移動は transform、出現は opacity のみ */}
<span className="absolute top-0 left-0 h-44 w-44 rounded-full bg-amber-300/25 opacity-0 blur-2xl transition-opacity duration-300 [transform:translate3d(var(--px,0px),var(--py,0px),0)_translate(-50%,-50%)] group-hover:opacity-100 motion-reduce:transition-none" />
{/* ゴースト連番: 板より小さく動かして視差の下層をつくる */}
<span className="font-display absolute -right-2 -bottom-6 text-[5rem] leading-none font-bold text-gray-100 tabular-nums transition-transform duration-[var(--tilt-ms,650ms)] ease-out [transform:translate3d(calc(var(--tx,0)*14px),calc(var(--ty,0)*14px),0)] motion-reduce:transition-none motion-reduce:[transform:none] @2xl:-right-3 @2xl:-bottom-8 @2xl:text-[7.5rem]">
{w.no}
</span>
</span>
{/* 起き上がりの合図。板の上端に引かれるアンバーの罫線 */}
<span
aria-hidden="true"
className="pointer-events-none absolute top-0 left-0 h-px w-full origin-left scale-x-0 bg-amber-600 transition-transform duration-500 ease-out group-focus-within:scale-x-100 group-hover:scale-x-100 motion-reduce:transition-none"
/>
{/* 本文。translateZ を持たせた子が自動的に手前に浮く */}
<a
href="#"
className="relative flex h-full min-h-[236px] flex-col justify-between p-6 [transform-style:preserve-3d] focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-amber-600 @2xl:p-8"
>
{/* 奥行き 24px: ロゴロックアップ */}
<div className="flex items-center gap-3 transition-transform duration-[var(--tilt-ms,650ms)] ease-out [transform:translateZ(24px)] motion-reduce:transition-none motion-reduce:[transform:none]">
<span
aria-hidden="true"
className="font-display flex h-8 w-8 shrink-0 items-center justify-center border border-gray-300 text-[11px] font-bold tracking-[0.06em] text-gray-500 transition-colors duration-300 group-hover:border-amber-600 group-hover:text-amber-600 motion-reduce:transition-none"
>
{w.monogram}
</span>
<span className="font-display min-w-0 truncate text-[13px] font-semibold tracking-[0.08em] text-gray-900">
{w.client}
</span>
<span className="ml-auto shrink-0 text-[10px] font-bold tracking-[0.18em] whitespace-nowrap text-gray-400">
{w.category}
</span>
</div>
{/* 奥行き 38px: 見出しと抜粋 */}
<div className="mt-6 transition-transform duration-[var(--tilt-ms,650ms)] ease-out [transform:translateZ(38px)] motion-reduce:transition-none motion-reduce:[transform:none]">
<h3 className="text-[15px] leading-[1.6] font-bold tracking-tight text-gray-900 @2xl:text-base">
{w.title}
</h3>
<p className="mt-3 text-[13px] leading-[1.9] text-gray-600">{w.excerpt}</p>
</div>
{/* 奥行き 16px: hairline のメタ行 */}
<div className="mt-6 flex items-baseline gap-4 border-t border-gray-200 pt-4 transition-transform duration-[var(--tilt-ms,650ms)] ease-out [transform:translateZ(16px)] motion-reduce:transition-none motion-reduce:[transform:none]">
<span className="shrink-0 text-[10px] font-bold tracking-[0.22em] text-gray-400">
{w.year}
</span>
<span className="min-w-0 text-[11px] tracking-[0.04em] text-gray-500">
{w.services}
</span>
<span
aria-hidden="true"
className="font-display ml-auto -translate-x-1.5 text-sm text-amber-600 opacity-0 transition duration-300 ease-out group-focus-within:translate-x-0 group-focus-within:opacity-100 group-hover:translate-x-0 group-hover:opacity-100 motion-reduce:transition-none"
>
→
</span>
</div>
</a>
</div>
</article>
))}
</div>
{/* 締めの1行 */}
<div className="mt-8 flex flex-wrap items-center justify-between gap-4 border-t border-gray-200 px-6 py-6 @2xl:px-10">
<p className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase">
Selected Works — 2025 / 2026
</p>
<a
href="#"
className="group relative inline-flex items-center gap-2 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
aria-hidden="true"
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
aria-hidden="true"
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>
<span aria-hidden="true" className="transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none">
→
</span>
</a>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/work-tilt-ledger.json