First CH Designs
BLK-20Blocks

3D Tilt & Parallax Hover Cards

A works section laid out as a hairline-ruled ledger where only the hovered entry tilts toward the cursor and lifts off the sheet. Pointer values are written straight into CSS custom properties (--tx/--ty/--px/--py) instead of React state, so tracking causes zero re-renders; the bounding rect is measured once on pointerenter and re-measured only on scroll. Rotation, per-layer parallax via translateZ, and the moving sheen are expressed purely with transform and opacity, and the 90ms follow / 650ms release time constants are swapped through a CSS variable. No dependencies. The effect is wired up only for fine pointers without prefers-reduced-motion; keyboard users get a focus-within rule and arrow instead of the tilt.

Added:
2026-08-11
Dependencies:
None
tags
#block #works #case-study #hover #3d #tilt #parallax #motion #no-image #no-dependency

Preview

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

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/work-tilt-ledger.json