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
プレビュー
Selected Works
数字が動いた一点を、光に当てる
すべての制作を並べるのではなく、数字が動いた一点だけを取り出しました。カーソルを重ねると、その一枚が台紙から起き上がります。
"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