First CH Designs
BLK-30Blocks

ガラス質3Dホバーカード

すりガラスの面・光沢の縁・浮いた本文の3層で奥行きを作る実績ブロック。光源はカードごとではなくグループに1つだけ置き、親で受けた1本の pointermove から全カードのローカル座標を毎フレーム算出するため、カーソルの下にないカードにも「近い辺だけが明るい」縁が出る。ポインタ位置から傾き(rotateX/rotateY)を求めるのはホバー中の1枚だけで、追従90ms/離脱650msの時定数まで CSS 変数(--lx/--ly/--near/--tx/--ty/--lift/--tilt-ms)で切り替えるので React の再レンダーはゼロ。白地では半透明板を重ねてもガラスに見えないため、面の背後に斜めの帯とアンバーのブロブ(ぼかし半径より粗い模様=1px級のドットは消える)を敷いてぼかす対象を作り、暗面の発光縁は明面の光沢ハイライトへ翻訳している。ライブラリ依存ゼロ。精密ポインタかつ prefers-reduced-motion でない環境でのみ配線し、キーボード操作には group-focus-within の光沢リングと矢印で同じ状態を返す(2026-08-18)。

追加日:
2026-08-18
依存:
なし
tags
#block #works #case-study #hover #3d #tilt #glass #glassmorphism #backdrop-blur #motion #no-image #no-dependency

プレビュー

"use client";

import { useCallback, useEffect, useRef } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["600", "700"],
  subsets: ["latin"],
  display: "swap",
  preload: false,
});

/**
 * ガラス質3Dホバーカード(BLK-30)
 *
 * 汎用技法メモ(web-design-playbook へ還流する要点)
 *
 * 1. 「光源はグループに1つ」— カードごとではなく親でポインタを受ける
 *    ホバー中のカードだけが反応する作りにすると、隣のカードは死んだ板に見える。
 *    親(グループ)で pointermove を1本だけ受け、1フレームにつき全カードぶんの
 *    ローカル座標を計算して各カードの CSS 変数へ書く。カーソルから離れたカードにも
 *    「近い辺だけが明るい」縁が出るので、面全体が同じ光の下にあるように見える。
 *    書き込むのは4つだけ: --lx/--ly(カード内のポインタ座標・px。カード外なら負値や
 *    幅超えの値になるが、それでよい=縁が手前側だけ光る)、--near(0〜1の近接度)、
 *    --tx/--ty(中心からの偏差 -1〜1・傾き用。内側にいるカードだけ非ゼロ)。
 *
 * 2. rect は「グループに入った瞬間」に全カードぶんまとめて測る
 *    pointermove ごとの getBoundingClientRect は強制同期レイアウトの主因。
 *    pointerenter で一括計測してキャッシュし、位置が変わる唯一のケースである
 *    スクロール(passive)とリサイズでだけ測り直す。書き込みは rAF で1フレーム1回に集約。
 *
 * 3. 白基調で「ガラス」を成立させるには、面の後ろに構造を置く
 *    backdrop-blur は背後にあるものをぼかすだけなので、白地に白の半透明板を重ねても
 *    何も起きない(暗面前提の作例をそのまま持ち込むと、この一点で破綻する)。
 *    板の下に斜めの帯とアンバーの淡いブロブを敷き、それをぼかすことで初めて
 *    「すりガラス」として読める。ここで敷く模様は blur 半径より粗くすること: 方眼ドットの
 *    ような1px級の模様は 6〜8px のぼかしで平均化されて完全に消える(実測で何も見えなかった)。
 *    帯の周期は 44px、ぼかしは 8px。暗面の発光縁も、明面では「光沢のハイライト」へ翻訳する。
 *
 * 4. backdrop-filter は preserve-3d を殺す(filter も同じ)
 *    backdrop-filter を持つ要素は強制的に flat 化されるため、ガラス面は 3D の
 *    「葉」として敷き、奥行きを持たせたい層(本文・縁・ブロブ)は同じ板の別の子にする。
 *    overflow-hidden も同様に preserve-3d を潰すので、クリップしたい層だけを別レイヤに切り出す。
 *
 * 5. 発光する縁は「1pxのリング」をマスクで作る
 *    box-shadow や outline はグラデーションを乗せられない。padding:1px の要素に
 *    ポインタ追従の radial-gradient を敷き、mask(content-box と border-box の
 *    exclude 合成)で中身をくり抜くと、光が回り込む1pxのリングになる。
 *    出し入れは opacity のみ(=合成のみで再描画が起きない)。
 *
 * 6. 追従は速く・離脱はゆっくり。時定数も CSS 変数で切り替える
 *    duration-[var(--tilt-ms,650ms)] としておき、カード内に入ったフレームで 90ms、
 *    出たフレームで 650ms を書く。クラスの付け外し(=再レンダー)なしで両立できる。
 *
 * 7. 触れない環境では配線しない
 *    (hover: hover) and (pointer: fine) かつ prefers-reduced-motion でないときだけ変数を書く。
 *    変数が未設定なら transform は恒等変換に畳まれるので、フォールバックの分岐は要らない。
 *    キーボード利用者にはティルトの代わりに group-focus-within の縁と矢印で状態を返す。
 *
 * 着想: ガラス質の面と発光する縁を層で重ねて奥行きを作り、ポインタ位置から傾きを算出する
 * 3Dホバー、という構造のみ。暗面前提の配色を白基調+アンバーへ翻訳し、コード・文言・構成は
 * すべて First CH のオリジナルとして書き起こしている(外部コード・素材の取り込みは無し)。
 */

type Work = {
  no: string;
  sector: string;
  tag: string;
  title: string;
  excerpt: string;
  metric: string;
  metricLabel: string;
  scope: string;
  year: string;
};

const works: Work[] = [
  {
    no: "01",
    sector: "MFG",
    tag: "製造",
    title: "見積の入口を、型番から聞く",
    excerpt:
      "問い合わせフォームの1問目を「ご用件」から「型番・用途」に組み替え、折り返しの電話を一往復減らしました。図面が手元にない人のための逃げ道も同じ画面に置いています。",
    metric: "+186%",
    metricLabel: "見積依頼 / 前年同期比",
    scope: "コーポレート / フォーム設計",
    year: "2026",
  },
  {
    no: "02",
    sector: "MED",
    tag: "医療",
    title: "診療時間を、探させない",
    excerpt: "休診の告知を固定の帯へ集約し、電話の前に答えが出る導線にしました。",
    metric: "−32%",
    metricLabel: "時間確認の入電",
    scope: "サイト改修 / 情報設計",
    year: "2026",
  },
  {
    no: "03",
    sector: "CON",
    tag: "建設",
    title: "応募の前に、現場を見せる",
    excerpt: "求人票より先に、働く場所と1日の流れを置いた採用ページへ。",
    metric: "2.1×",
    metricLabel: "応募数 / 半期",
    scope: "採用サイト / 撮影ディレクション",
    year: "2025",
  },
];

// 主役のカードだけに添える施策のメモ(従カードとの余白差=主従の差にする)
const steps = [
  "入力順を「用途 → 型番 → 連絡先」へ組み替え",
  "図面が手元にない人向けに写真の添付欄を追加",
  "自動返信に、次に聞かれることの一覧を同梱",
];

// 1pxのリングを作るマスク(content-box と border-box を exclude で合成)。
// Tailwind の任意値では -webkit- 前置きが書きづらいのでスタイルオブジェクトで持つ。
const ringMask = {
  WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
  WebkitMaskComposite: "xor",
  mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
  maskComposite: "exclude",
} as const;

// ポインタへ集まるアンバーの光沢。カード外にポインタがあるときは手前側の辺だけが光る。
const edgeLight =
  "radial-gradient(200px circle at var(--lx, 50%) var(--ly, 50%), rgba(217,119,6,0.95), rgba(217,119,6,0.35) 42%, rgba(217,119,6,0) 72%)";

function GlassCard({
  work,
  featured,
  register,
}: {
  work: Work;
  featured: boolean;
  register: (el: HTMLDivElement | null) => void;
}) {
  return (
    <article className="group relative h-full [perspective:1100px]">
      {/* 落ち影は別レイヤで opacity だけ動かす(box-shadow はアニメーションさせない) */}
      <span
        aria-hidden="true"
        className="pointer-events-none absolute inset-3 rounded-2xl opacity-[var(--near,0)] shadow-[0_34px_60px_-28px_rgba(120,53,15,0.6)] transition-opacity duration-500 motion-reduce:transition-none"
      />

      {/* 傾く板(preserve-3d。overflow と backdrop-filter はここに置かない) */}
      <div
        ref={register}
        className="relative h-full [transform:translate3d(0,0,var(--lift,0px))_rotateX(calc(var(--ty,0)*-4.5deg))_rotateY(calc(var(--tx,0)*6.5deg))] [transform-style:preserve-3d] transition-transform duration-[var(--tilt-ms,650ms)] ease-out will-change-transform motion-reduce:[transform:none] motion-reduce:transition-none"
      >
        {/* ①下地: 斜めの帯+アンバーのブロブ(ガラスがぼかす対象。ここだけクリップする) */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl bg-white [background-image:repeating-linear-gradient(115deg,rgba(28,25,23,0.085)_0_10px,rgba(28,25,23,0)_10px_44px)]"
        >
          <span className="absolute -top-10 -left-8 h-40 w-40 rounded-full bg-amber-400/20 blur-2xl [transform:translate3d(calc(var(--tx,0)*14px),calc(var(--ty,0)*14px),0)] transition-transform duration-[var(--tilt-ms,650ms)] ease-out motion-reduce:[transform:none] motion-reduce:transition-none" />
          <span className="absolute -right-12 -bottom-14 h-44 w-44 rounded-full bg-amber-300/16 blur-2xl [transform:translate3d(calc(var(--tx,0)*-18px),calc(var(--ty,0)*-18px),0)] transition-transform duration-[var(--tilt-ms,650ms)] ease-out motion-reduce:[transform:none] motion-reduce:transition-none" />
          <span
            className={`${montserrat.className} absolute -right-1 -bottom-7 text-[5.5rem] leading-none font-bold text-stone-900/[0.07] tabular-nums`}
          >
            {work.no}
          </span>
        </span>

        {/* ②ガラス面: 背後の下地をぼかす。backdrop-filter を持つので3Dの葉として置く */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 rounded-2xl bg-white/40 [background-image:linear-gradient(140deg,rgba(255,255,255,0.88),rgba(255,255,255,0.12)_40%,rgba(255,255,255,0.62))] shadow-[inset_1px_1px_0_rgba(255,255,255,0.9),inset_-1px_-10px_20px_-12px_rgba(120,53,15,0.35)] backdrop-blur-[8px] backdrop-saturate-150"
        />

        {/* ③縁: 常時のヘアラインと、ポインタへ集まる光沢リング */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-stone-900/10 ring-inset"
        />
        <span
          aria-hidden="true"
          style={{ ...ringMask, backgroundImage: edgeLight }}
          className="pointer-events-none absolute inset-0 rounded-2xl p-px opacity-[var(--near,0)] transition-opacity duration-300 group-focus-within:opacity-100 motion-reduce:transition-none"
        />

        {/* ④本文: 層ごとに translateZ を変えて視差を出す(ラッパにも preserve-3d が要る) */}
        <a
          href="#"
          className={`relative flex h-full [transform-style:preserve-3d] flex-col rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-amber-600 ${
            featured ? "gap-6 p-7 @2xl:p-8" : "gap-4 p-6"
          }`}
        >
          <div className="flex items-center gap-3 [transform:translateZ(16px)] transition-transform duration-[var(--tilt-ms,650ms)] ease-out motion-reduce:[transform:none] motion-reduce:transition-none">
            <span
              className={`${montserrat.className} flex h-7 items-center rounded-sm bg-stone-900/[0.04] px-2 text-[10px] font-semibold tracking-[0.18em] text-stone-500 transition-colors duration-300 group-hover:bg-amber-600/10 group-hover:text-amber-700 group-focus-within:bg-amber-600/10 group-focus-within:text-amber-700`}
            >
              {work.sector}
            </span>
            <span className="text-[11px] font-bold tracking-[0.18em] whitespace-nowrap text-stone-500">
              {work.tag}
            </span>
            <span
              className={`${montserrat.className} ml-auto text-[11px] font-semibold tracking-[0.14em] text-stone-400 tabular-nums`}
            >
              {work.year}
            </span>
          </div>

          <div className="[transform:translateZ(34px)] transition-transform duration-[var(--tilt-ms,650ms)] ease-out motion-reduce:[transform:none] motion-reduce:transition-none">
            <h3
              className={`font-bold tracking-tight text-stone-900 ${
                featured ? "text-lg leading-[1.5] @2xl:text-xl" : "text-[15px] leading-[1.55]"
              }`}
            >
              {work.title}
            </h3>
            <p
              className={`mt-3 text-stone-600 ${
                featured ? "text-[13px] leading-[1.95]" : "text-[12px] leading-[1.9]"
              }`}
            >
              {work.excerpt}
            </p>
          </div>

          {featured && (
            <ul className="grid gap-2.5 border-t border-stone-900/10 pt-4 text-[12px] leading-[1.7] text-stone-600 [transform:translateZ(28px)] transition-transform duration-[var(--tilt-ms,650ms)] ease-out motion-reduce:[transform:none] motion-reduce:transition-none">
              {steps.map((step) => (
                <li key={step} className="flex gap-2.5">
                  <span
                    aria-hidden="true"
                    className="mt-[0.65em] h-px w-3 shrink-0 bg-amber-600"
                  />
                  {step}
                </li>
              ))}
            </ul>
          )}

          <div className="mt-auto flex items-end gap-4 border-t border-stone-900/10 pt-4 [transform:translateZ(22px)] transition-transform duration-[var(--tilt-ms,650ms)] ease-out motion-reduce:[transform:none] motion-reduce:transition-none">
            <div className="min-w-0">
              <p
                className={`${montserrat.className} leading-none font-bold text-amber-600 tabular-nums ${
                  featured ? "text-3xl @2xl:text-4xl" : "text-2xl"
                }`}
              >
                {work.metric}
              </p>
              <p className="mt-2 truncate text-[10px] font-medium tracking-[0.08em] text-stone-500">
                {work.metricLabel}
              </p>
            </div>
            <span
              aria-hidden="true"
              className="mb-1 ml-auto -translate-x-1.5 text-sm text-amber-600 opacity-0 transition duration-300 group-hover:translate-x-0 group-hover:opacity-100 group-focus-within:translate-x-0 group-focus-within:opacity-100 motion-reduce:translate-x-0 motion-reduce:transition-none"
            >
              →
            </span>
          </div>

          {featured && (
            <p className="text-[11px] tracking-[0.06em] text-stone-500 [transform:translateZ(12px)] transition-transform duration-[var(--tilt-ms,650ms)] ease-out motion-reduce:[transform:none] motion-reduce:transition-none">
              {work.scope}
            </p>
          )}
        </a>
      </div>
    </article>
  );
}

export default function NeonGlass3dCards() {
  const plates = useRef<(HTMLDivElement | null)[]>([]);
  const rects = useRef<(DOMRect | null)[]>([]);
  const pointer = useRef({ x: 0, y: 0 });
  const raf = useRef(0);
  const enabled = useRef(false);

  const measure = useCallback(() => {
    rects.current = plates.current.map((el) => (el ? el.getBoundingClientRect() : null));
  }, []);

  useEffect(() => {
    // 精密ポインタかつ reduced-motion でない環境でだけ配線する
    enabled.current =
      window.matchMedia("(hover: hover) and (pointer: fine)").matches &&
      !window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // hover 中に位置が変わるのはスクロールとリサイズだけ。そこでのみ測り直す。
    const onChange = () => {
      if (rects.current.length) measure();
    };
    window.addEventListener("scroll", onChange, { passive: true });
    window.addEventListener("resize", onChange);
    return () => {
      window.removeEventListener("scroll", onChange);
      window.removeEventListener("resize", onChange);
      cancelAnimationFrame(raf.current);
    };
  }, [measure]);

  // 1フレームに1回、全カードぶんの光と傾きを CSS 変数へ書く(state は経由しない)
  const flush = useCallback(() => {
    raf.current = 0;
    const { x, y } = pointer.current;
    plates.current.forEach((el, i) => {
      const r = rects.current[i];
      if (!el || !r) return;
      const lx = x - r.left;
      const ly = y - r.top;
      const inside = lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height;
      // カード矩形の外側までの距離(内側なら0)。180px で光が消えるようになだらかに落とす。
      const gapX = Math.max(0, Math.abs(lx - r.width / 2) - r.width / 2);
      const gapY = Math.max(0, Math.abs(ly - r.height / 2) - r.height / 2);
      const near = Math.max(0, 1 - Math.hypot(gapX, gapY) / 180);
      el.style.setProperty("--lx", `${lx.toFixed(1)}px`);
      el.style.setProperty("--ly", `${ly.toFixed(1)}px`);
      el.style.setProperty("--near", near.toFixed(3));
      el.style.setProperty("--tx", inside ? ((lx / r.width) * 2 - 1).toFixed(3) : "0");
      el.style.setProperty("--ty", inside ? ((ly / r.height) * 2 - 1).toFixed(3) : "0");
      el.style.setProperty("--lift", inside ? "18px" : "0px");
      el.style.setProperty("--tilt-ms", inside ? "90ms" : "420ms");
    });
  }, []);

  const onEnter = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      if (!enabled.current) return;
      measure();
      pointer.current = { x: e.clientX, y: e.clientY };
      if (!raf.current) raf.current = requestAnimationFrame(flush);
    },
    [flush, measure],
  );

  const onMove = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      if (!enabled.current || !rects.current.length) return;
      pointer.current = { x: e.clientX, y: e.clientY };
      if (!raf.current) raf.current = requestAnimationFrame(flush);
    },
    [flush],
  );

  const onLeave = useCallback(() => {
    cancelAnimationFrame(raf.current);
    raf.current = 0;
    rects.current = [];
    plates.current.forEach((el) => {
      if (!el) return;
      el.style.setProperty("--tilt-ms", "650ms"); // 戻りはゆっくり
      el.style.setProperty("--near", "0");
      el.style.setProperty("--tx", "0");
      el.style.setProperty("--ty", "0");
      el.style.setProperty("--lift", "0px");
    });
  }, []);

  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">
          Selected Works
        </p>
        <h2 className="mt-3 text-[clamp(1.45rem,3.4vw,2rem)] font-bold tracking-tight text-stone-900">
          数字が動いた一点を、光に当てる
        </h2>
        <p className="mt-3 max-w-lg text-sm leading-[1.9] text-stone-600">
          すべての制作を並べるのではなく、数字が動いた一点だけを取り出しました。カーソルを重ねると、その一枚が台紙から起き上がります。
        </p>
      </div>

      {/* 光源はこの1箇所で受ける(カードごとには張らない) */}
      <div
        onPointerEnter={onEnter}
        onPointerMove={onMove}
        onPointerLeave={onLeave}
        className="mt-8 grid gap-4 px-6 @2xl:grid-cols-[1.12fr_1fr] @2xl:gap-5 @2xl:px-10"
      >
        <GlassCard
          work={works[0]}
          featured
          register={(el) => {
            plates.current[0] = el;
          }}
        />
        <div className="grid gap-4 @2xl:gap-5">
          {works.slice(1).map((work, i) => (
            <GlassCard
              key={work.no}
              work={work}
              featured={false}
              register={(el) => {
                plates.current[i + 1] = el;
              }}
            />
          ))}
        </div>
      </div>

      <div className="mt-8 border-t border-gray-200 px-6 py-6 @2xl:px-10">
        <a
          href="#"
          className="group inline-flex items-center gap-3 text-sm font-semibold text-stone-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-stone-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>
          <span
            aria-hidden="true"
            className="inline-block 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/neon-glass-3d-cards.json