First CH Designs
BLK-30Blocks

Frosted Glass 3D Hover Cards

A works block that builds depth from three stacked layers: a frosted pane, a specular rim, and content floating above it. The light source belongs to the group rather than to each card — a single pointermove on the parent drives per-card local coordinates every frame, so even cards the cursor is not over light up along the edge facing it. Only the hovered card derives rotateX/rotateY from the pointer, and every value including the 90ms follow / 650ms release time constants is swapped through CSS custom properties (--lx/--ly/--near/--tx/--ty/--lift/--tilt-ms), so React never re-renders. Because a translucent pane over white reads as nothing, diagonal bands and amber blobs sit behind the glass to give the blur something to work on (the pattern has to be coarser than the blur radius), translating dark-mode neon edges into light-mode specular highlights. No dependencies; wired up only for fine pointers without prefers-reduced-motion, with a focus-within rim and arrow for keyboard users.

Added:
2026-08-18
Dependencies:
None
tags
#block #works #case-study #hover #3d #tilt #glass #glassmorphism #backdrop-blur #motion #no-image #no-dependency

Preview

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

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/neon-glass-3d-cards.json