リキッドガラス・ヒーロー背景(WebGL屈折+メルト)
フルスクリーンの三角形1枚とフラグメントシェーダ1本だけで、ヒーロー背景に屈折するガラスの塊を敷いたブロック。3Dライブラリは使わず素のWebGL(GLSL ES 1.00)で組んでいるので、webgl2でもwebglでも同じシェーダが動き、依存ライブラリはゼロ。背景の方眼ドットとアンバーの斜め帯はテクスチャではなくシェーダ内の関数で描き、それを法線方向にずらして再サンプルすることで屈折を作る(R/G/Bで屈折率を1.035/1.00/0.965とずらす色収差つき)。塊どうしは距離場のsmin(多項式スムーズ最小値)で結合するため、近づくと首を作って溶け合う=メルトになり、粘性kを時間で揺らすと呼吸して見える。ポインタは6つ目の塊として振る舞い、無操作が続くとリサージュ曲線の仮想ポインタが引き継ぐのでタッチ環境でも成立する。低スペック対策は解像度スケールの二段落とし(起動直後48フレームの平均が32msを超えたらバッキングストアを0.6倍、それでも超えたらrAFを止めてCSSの静的グラデーションへ降りる)。prefers-reduced-motion時はrAFを回さず同じシェーダで静止フレームを1枚だけ描き、画面外(IntersectionObserver)とタブ非表示でも停止する。webglcontextlostは必ずpreventDefaultで握り、アンマウント時はWEBGL_lose_contextでGPUリソースを明示的に解放する(同種ブロックが並ぶ一覧ページではコンテキスト数の上限に当たるため)。テキスト・配色・構図はすべてFirst CHのオリジナル、画像素材ゼロ。屈折・メルトのシェーダーをヒーロー背景に敷き通常のDOMを上に重ねるという構造だけを着想として再構築した(外部コードの取り込みなし・2026-08-14)。
- 追加日:
- 2026-08-14
- 依存:
- なし
- tags
- #hero #page #webgl #glsl #shader #glass #refraction #sdf #interactive #motion #no-image #no-dependency
プレビュー
"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({ weight: ["700"], subsets: ["latin"], display: "swap" });
/**
* リキッドガラス・ヒーロー背景(PGE-05)
*
* 汎用技法メモ(web-design-playbook へ還流する要点):
* 1. 「ガラス」はフルスクリーン三角形2枚+フラグメントシェーダ1本で足りる。
* 3Dライブラリを積むとバンドルが数百KB増えるだけで、板ポリ1枚に必要な機能は
* 素の WebGL(GLSL ES 1.00)で全部そろう。webgl2 → webgl の順に取得すれば
* どちらでも同じシェーダが動く(#version を書かなければ WebGL2 も 1.00 を受ける)。
* 2. 屈折は「背景をずらしてサンプルし直す」だけ。ただし**背景に構造がないとガラスに見えない**
* ので、方眼ドット+斜めの帯を手続き的に描いてから曲げる。背景が関数なので
* テクスチャもフレームバッファも不要(=GPUメモリを持たない)。
* 3. メルト(溶け合い)は距離場の smin(多項式スムーズ最小値)で得る。
* 円のSDFを smin で結合すると、近づいた塊が首を作ってつながる。k を時間で
* 揺らすと「粘性が呼吸する」ように見える。
* 4. 法線は距離場の勾配(中心差分)。これが屈折方向・フレネル・ハイライトの
* 3つを兼ねるので、法線マップも光源計算も要らない。
* 5. 色収差は R/G/B で屈折率を 1.07 / 1.00 / 0.93 とずらして3回サンプルするだけ。
* 白基調では「わずかな縁の色づき」として効き、彩度を上げずに質感が出る。
* 6. 低スペック対策は解像度スケールの二段落とし: 起動直後の48フレームの平均フレーム時間を
* 測り、32ms を超えたら backing store を 0.6 倍に、それでも超えたら rAF を止めて
* CSSの静的グラデーションへ降りる(canvas の下に常設しておき opacity で入れ替える)。
* 7. 停止条件を3つ持つ: prefers-reduced-motion / IntersectionObserver(画面外)/
* visibilitychange(タブ非表示)。加えて webglcontextlost では必ず preventDefault し、
* アンマウント時は WEBGL_lose_context でGPUリソースを明示的に解放する
* (同種ブロックが並ぶ一覧ページではコンテキスト数の上限に当たる)。
*
* 着想: 屈折・メルトのシェーダーをヒーロー背景に敷き、通常のDOMを上に重ねるという構造。
* シェーダ・文言・配色・構図は First CH のオリジナルとして新規に書き起こしている。
*/
const VERT = `attribute vec2 aPos;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 uRes; // 論理px(CSSピクセル)
uniform float uTime; // 秒
uniform vec2 uPtr; // ポインタ(論理px・左上原点)
uniform float uPress; // 押し込み 0..1
uniform float uDpr; // バッキングストア倍率
const vec3 INK = vec3(0.133, 0.118, 0.098);
const vec3 AMBER = vec3(0.851, 0.467, 0.024);
const vec3 WARM = vec3(1.035, 0.995, 0.952);
// 多項式スムーズ最小値。距離場どうしを「溶け合わせる」ための核。
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
// 屈折で曲げる対象。手続き的に描くのでテクスチャを持たない。
vec3 background(vec2 p) {
vec3 col = vec3(1.0);
// 斜めのアンバー帯(色が動くための地)
float band = sin((p.x * 0.66 + p.y * 1.12) * 0.0105 - uTime * 0.11);
col = mix(col, mix(vec3(1.0), AMBER, 0.13), smoothstep(0.32, 1.0, band));
// 右下へ落とすインクのにじみ
float ink = 1.0 - smoothstep(0.05, 0.95, length(p / uRes - vec2(0.97, 0.95)));
col = mix(col, mix(col, INK, 0.11), ink);
// ドット方眼(標本モチーフ)
float g = 26.0;
float grid = length((fract(p / g) - 0.5) * g);
col = mix(col, mix(col, INK, 0.34), smoothstep(1.8, 0.7, grid));
// 6セルごとのヘアライン(計測の目盛り)
vec2 t6 = fract(p / (g * 6.0));
vec2 b6 = min(t6, 1.0 - t6) * (g * 6.0);
float rule = min(b6.x, b6.y);
col = mix(col, mix(col, INK, 0.12), smoothstep(1.0, 0.0, rule));
return col;
}
float scale() {
return clamp(min(uRes.x, uRes.y) / 460.0, 0.62, 1.7);
}
// 円のSDFを smin で結合した距離場。負の側がガラスの内部。
float field(vec2 p) {
float sc = scale();
float k = (32.0 + 13.0 * sin(uTime * 0.23)) * sc; // 粘性=溶け合いの強さ
// ポインタが6つ目の塊として振る舞う
float d = length(p - uPtr) - (84.0 + uPress * 30.0) * sc;
for (int i = 0; i < 5; i++) {
float fi = float(i);
float a = fi * 2.3999632; // 黄金角で位相を散らす
vec2 c = uRes * vec2(
0.62 + 0.30 * sin(uTime * (0.19 + 0.043 * fi) + a),
0.50 + 0.29 * sin(uTime * (0.15 + 0.037 * fi) + a * 1.7 + 1.1)
);
float r = (46.0 + 25.0 * sin(a * 1.3)) * sc;
d = smin(d, length(p - c) - r, k);
}
return d;
}
void main() {
vec2 fc = gl_FragCoord.xy / uDpr;
vec2 p = vec2(fc.x, uRes.y - fc.y); // DOM と同じ左上原点へ
float sc = scale();
float d = field(p);
vec3 col = background(p);
// 接地の影(ガラスの外側だけ、右下へ少しずらして拾う)
float sh = smoothstep(0.0, -36.0 * sc, field(p - vec2(9.0, 16.0) * sc));
col *= 1.0 - sh * 0.075;
float mask = smoothstep(1.2, -1.2, d); // 縁のアンチエイリアス
if (mask > 0.002) {
float e = 1.6;
vec2 n = vec2(
field(p + vec2(e, 0.0)) - field(p - vec2(e, 0.0)),
field(p + vec2(0.0, e)) - field(p - vec2(0.0, e))
);
n = normalize(n + vec2(1e-5, 1e-5));
float depth = smoothstep(0.0, -32.0 * sc, d); // 0:縁 → 1:芯
float rim = pow(1.0 - depth, 1.4); // 縁ほど強く曲がる
// 縁は外へ大きく、芯は内へ少し。芯を内へ引くと方眼が拡大されて「厚み」が出る
vec2 off = n * (rim * 78.0 - depth * 15.0) * sc;
// 色収差: R/G/B で屈折率をずらす(開きすぎると方眼ドットが虹色の粒に割れる)
vec3 refr;
refr.r = background(p + off * 1.035).r;
refr.g = background(p + off).g;
refr.b = background(p + off * 0.965).b;
// ガラス本体は少し明るく・少し暖かい(上げすぎると背後の方眼が消えてミルクになる)
refr = mix(refr, vec3(1.0), 0.05 * depth);
refr *= mix(vec3(1.0), WARM, depth);
// フレネルの縁光(広げるとガラスでなく発光体に見えるので、縁1〜2pxに絞る)
refr += pow(1.0 - depth, 7.0) * 0.26;
// 左上からの指向性ハイライトと、右下側のアンバー反射
float spec = pow(max(0.0, dot(n, normalize(vec2(-0.55, -0.83)))), 3.0);
refr += spec * rim * 0.34;
float back = pow(max(0.0, dot(n, normalize(vec2(0.62, 0.78)))), 2.4);
refr = mix(refr, AMBER, back * rim * 0.22);
col = mix(col, refr, mask);
}
// ごく浅いグレイン(白基調の階調バンディング止め)
float grain = fract(sin(dot(fc, vec2(12.9898, 78.233))) * 43758.5453);
col += (grain - 0.5) * 0.012;
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}`;
const IDLE_MS = 2600; // これだけ触られなければ仮想ポインタへ引き継ぐ
export default function LiquidGlassHero({
headingTag: Heading = "h1",
}: {
/**
* 見出しのタグ。実案件ではそのまま(h1)。ギャラリーのプレビューは1ページに複数の標本が
* 並ぶため "p" を渡してh1の重複を避ける(見た目はclassNameで決まるので変わらない)。
*/
headingTag?: "h1" | "p";
}) {
const [inView, setInView] = useState(false);
const [ready, setReady] = useState(false);
const [fallback, setFallback] = useState(false);
const [touched, setTouched] = useState(false);
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// マウント後にリビール開始(reduced-motion 時は motion-reduce: で即時表示)
useEffect(() => {
const id = requestAnimationFrame(() => setInView(true));
return () => cancelAnimationFrame(id);
}, []);
useEffect(() => {
const stage = stageRef.current;
const canvas = canvasRef.current;
if (!stage || !canvas) return;
const attrs: WebGLContextAttributes = {
alpha: false,
antialias: false,
depth: false,
stencil: false,
preserveDrawingBuffer: false,
powerPreference: "low-power",
};
const gl =
(canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null) ??
(canvas.getContext("webgl", attrs) as WebGLRenderingContext | null);
if (!gl) {
setFallback(true);
return;
}
const compile = (type: number, src: string) => {
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
gl.deleteShader(sh);
return null;
}
return sh;
};
const vs = compile(gl.VERTEX_SHADER, VERT);
const fs = compile(gl.FRAGMENT_SHADER, FRAG);
const program = vs && fs ? gl.createProgram() : null;
if (!vs || !fs || !program) {
// 片方だけ通ったときに置き去りにしない
if (vs) gl.deleteShader(vs);
if (fs) gl.deleteShader(fs);
setFallback(true);
return;
}
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
// シェーダは link 後に detach/delete してよい(プログラムが参照を保持する)
gl.detachShader(program, vs);
gl.detachShader(program, fs);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
setFallback(true);
return;
}
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]), // 画面を覆う大三角形1枚
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(program, "aPos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.useProgram(program);
const uRes = gl.getUniformLocation(program, "uRes");
const uTime = gl.getUniformLocation(program, "uTime");
const uPtr = gl.getUniformLocation(program, "uPtr");
const uPress = gl.getUniformLocation(program, "uPress");
const uDpr = gl.getUniformLocation(program, "uDpr");
let w = 1;
let h = 1;
let backing = 1;
let quality = 1;
let raf = 0;
let last = 0;
let clock = 4800; // 静止フレームでも構図が決まる位相から始める
let press = 0;
let dead = false;
let painted = false;
let placed = false;
// 実ポインタの目標値と、それを追いかける演出用ポインタ
const target = { x: 0, y: 0, live: false, at: -Infinity };
const cursor = { x: 0, y: 0 };
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
let onScreen = true;
let visible = document.visibilityState !== "hidden";
let firstMove = false;
// 起動直後のフレーム時間を測って解像度を二段で落とす低スペック対策
let probe = 0;
let probeAcc = 0;
let downgraded = false;
const applySize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 1.75);
backing = Math.max(0.6, dpr * quality);
canvas.width = Math.max(1, Math.round(w * backing));
canvas.height = Math.max(1, Math.round(h * backing));
gl.viewport(0, 0, canvas.width, canvas.height);
};
const render = () => {
// ResizeObserver が実寸を返す前に描くと 1x1 の破片が一瞬見えるので待つ
if (w <= 1 || h <= 1) return;
gl.uniform2f(uRes, w, h);
gl.uniform1f(uTime, clock / 1000);
gl.uniform2f(uPtr, cursor.x, cursor.y);
gl.uniform1f(uPress, press);
gl.uniform1f(uDpr, backing);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// 初回描画までは canvas を透明にしておき、静的グラデーションから継ぎ目なく入れ替える
if (!painted) {
painted = true;
setReady(true);
}
};
const step = (dtScale: number) => {
// 実ポインタが止まって IDLE_MS 経てば Lissajous の仮想ポインタへ引き継ぐ
const idle = !target.live || clock - target.at > IDLE_MS;
let tx = target.x;
let ty = target.y;
if (idle) {
const a = clock * 0.00024;
tx = w * (0.34 + 0.28 * Math.sin(a));
ty = h * (0.5 + 0.3 * Math.sin(a * 1.57 + 0.8));
}
cursor.x += (tx - cursor.x) * Math.min(1, 0.09 * dtScale);
cursor.y += (ty - cursor.y) * Math.min(1, 0.09 * dtScale);
press *= Math.pow(0.94, dtScale);
};
const stop = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const frame = (time: number) => {
if (!last) last = time;
const dt = Math.min(50, time - last);
last = time;
clock += dt;
step(dt / 16.6667);
render();
// 立ち上がりの10フレームを捨ててから48フレームの平均で判定
if (probe >= 0) {
probe++;
if (probe > 10) probeAcc += dt;
if (probe === 58) {
const avg = probeAcc / 48;
if (avg > 32) {
if (!downgraded) {
downgraded = true;
quality = 0.6;
applySize();
probe = 0;
probeAcc = 0;
} else {
probe = -1;
dead = true;
stop();
setFallback(true);
return;
}
} else {
probe = -1;
}
}
}
raf = requestAnimationFrame(frame);
};
const start = () => {
if (raf || dead || mql.matches || !onScreen || !visible) return;
last = 0;
raf = requestAnimationFrame(frame);
};
// モーション低減時は rAF を回さず、同じシェーダで静止フレームを1枚だけ描く
const drawStatic = () => {
target.live = false;
cursor.x = w * 0.3;
cursor.y = h * 0.46;
press = 0;
render();
};
const ro = new ResizeObserver((entries) => {
// contentRect はレイアウト寸法(CSS transform の影響を受けない)
const box = entries[0].contentRect;
w = Math.max(1, Math.round(box.width));
h = Math.max(1, Math.round(box.height));
applySize();
if (!placed) {
// 実寸が分かるまでカーソルの初期位置は決められない
placed = true;
cursor.x = w * 0.3;
cursor.y = h * 0.46;
}
if (!raf) drawStatic();
});
ro.observe(stage);
const io = new IntersectionObserver(
(entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) start();
else stop();
},
{ rootMargin: "120px" },
);
io.observe(stage);
const onVisibility = () => {
visible = document.visibilityState !== "hidden";
if (visible) start();
else stop();
};
const toLocal = (clientX: number, clientY: number) => {
const rect = stage.getBoundingClientRect();
if (!rect.width || !rect.height) return;
// 変形後の矩形 → 論理座標へ比率で換算(親に scale が掛かっていても合う)
target.x = ((clientX - rect.left) / rect.width) * w;
target.y = ((clientY - rect.top) / rect.height) * h;
target.live = true;
target.at = clock;
};
const onMove = (e: PointerEvent) => {
toLocal(e.clientX, e.clientY);
if (!firstMove) {
firstMove = true;
setTouched(true);
}
};
const onDown = (e: PointerEvent) => {
toLocal(e.clientX, e.clientY);
press = 1;
if (!firstMove) {
firstMove = true;
setTouched(true);
}
};
const onLeave = () => {
target.live = false;
};
const onMotionChange = () => {
if (mql.matches) {
stop();
drawStatic();
} else {
start();
}
};
// コンテキスト消失は既定でそのまま欠落するので、必ず握って静的側へ降りる
const onLost = (e: Event) => {
e.preventDefault();
dead = true;
stop();
setFallback(true);
};
stage.addEventListener("pointermove", onMove, { passive: true });
stage.addEventListener("pointerdown", onDown, { passive: true });
stage.addEventListener("pointerleave", onLeave, { passive: true });
document.addEventListener("visibilitychange", onVisibility);
mql.addEventListener("change", onMotionChange);
canvas.addEventListener("webglcontextlost", onLost);
if (mql.matches) drawStatic();
else start();
return () => {
stop();
ro.disconnect();
io.disconnect();
stage.removeEventListener("pointermove", onMove);
stage.removeEventListener("pointerdown", onDown);
stage.removeEventListener("pointerleave", onLeave);
document.removeEventListener("visibilitychange", onVisibility);
mql.removeEventListener("change", onMotionChange);
canvas.removeEventListener("webglcontextlost", onLost);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
// 一覧ページでは同時コンテキスト数の上限に当たるので明示的に手放す
gl.getExtension("WEBGL_lose_context")?.loseContext();
};
}, []);
// Tailwind は動的な [transition-delay:*] を静的解析できないので delay は style で渡す
const revealClass = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
inView
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`;
const delay = (ms: number) => ({ transitionDelay: `${ms}ms` });
const specs = [
{ key: "屈折", body: "背景の方眼をレンズが曲げる" },
{ key: "融合", body: "近づいた塊どうしが溶け合う" },
{ key: "追従", body: "ポインタが6つ目の塊になる" },
];
return (
<div
ref={stageRef}
className="relative w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
>
{/* WebGLが使えない/低スペックで降りたときの静的グラデーション(canvasの下に常設) */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_100%_at_74%_16%,rgba(217,119,6,0.18)_0%,rgba(217,119,6,0.06)_36%,rgba(255,255,255,0)_66%),radial-gradient(90%_80%_at_96%_92%,rgba(34,30,25,0.1)_0%,rgba(255,255,255,0)_58%),linear-gradient(160deg,#ffffff_0%,#fbfaf7_58%,#f6f3ee_100%)]"
/>
<canvas
ref={canvasRef}
aria-hidden="true"
className={`absolute inset-0 block h-full w-full transition-opacity duration-700 ease-out motion-reduce:transition-none ${
ready && !fallback ? "opacity-100" : "opacity-0"
}`}
/>
{/* 文字の背後だけ白へ沈める。右側は洗わずガラスの見せ場として残す */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(105%_92%_at_6%_54%,rgba(255,255,255,0.97)_0%,rgba(255,255,255,0.8)_40%,rgba(255,255,255,0)_78%)] sm:bg-[radial-gradient(66%_90%_at_0%_54%,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.82)_46%,rgba(255,255,255,0)_100%)]"
/>
<div className="relative flex min-h-[460px] flex-col sm:min-h-[540px]">
{/* 上部のヘアライン・メタバー(見出しブロックと余白で分ける) */}
<div className="flex items-center justify-between gap-4 border-b border-gray-200/70 px-6 py-3.5 sm:px-10 sm:py-4">
<p className="flex items-center gap-2 text-[11px] font-bold tracking-[0.16em] whitespace-nowrap text-gray-900">
<span aria-hidden="true" className="inline-block size-1 bg-amber-600" />
Refraction Field
</p>
<p
aria-hidden="true"
className="font-mono text-[10px] font-bold tracking-[0.18em] whitespace-nowrap text-gray-400 uppercase"
>
Melt / 06 orbs
</p>
</div>
<div className="flex flex-1 flex-col justify-center px-6 py-12 sm:px-10 sm:py-16">
<p
style={delay(0)}
className={`${revealClass} text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase`}
>
First CH Interactive
</p>
<Heading
style={delay(120)}
className={`${montserrat.className} ${revealClass} mt-5 text-[clamp(2.2rem,6.2vw,3.9rem)] leading-[1.05] font-bold tracking-tight text-gray-900`}
>
Light bends here.
</Heading>
<p
style={delay(240)}
className={`${revealClass} mt-6 max-w-md text-[15px] leading-[1.95] font-medium tracking-[0.03em] text-gray-700 sm:text-[16px]`}
>
触れた場所から画面が溶ける。First CH は、質感のある表現を軽いコードのまま実装します。
</p>
<div
style={delay(360)}
className={`${revealClass} mt-9 max-w-lg border-t border-gray-200 pt-5`}
>
<dl className="flex flex-col gap-3 sm:flex-row sm:gap-0">
{specs.map((s, i) => (
<div
key={s.key}
className={`flex items-baseline gap-3 sm:flex-1 sm:flex-col sm:items-start sm:gap-1.5 ${
i > 0 ? "sm:border-l sm:border-gray-200 sm:pl-5" : ""
}`}
>
<dt className="text-[13px] font-bold tracking-[0.12em] whitespace-nowrap text-gray-900">
{s.key}
</dt>
<dd className="text-[12px] leading-relaxed text-gray-500">{s.body}</dd>
</div>
))}
</dl>
</div>
<div
style={delay(480)}
className={`${revealClass} mt-9 flex flex-wrap items-center gap-x-8 gap-y-4`}
>
<a
href="#"
className="group inline-flex items-center gap-3 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 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 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>
<a
href="#"
className="group inline-flex items-center gap-2 text-sm font-medium text-gray-500 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
実装の考え方を見る
<span
aria-hidden="true"
className="inline-block h-px w-5 bg-gray-300 transition-transform duration-300 ease-out group-hover:scale-x-150 group-hover:bg-amber-600 motion-reduce:transition-none"
/>
</a>
</div>
</div>
</div>
{/* 操作ヒント: 一度でも操作したら消える。文言はポインタ種別で純CSS切替 */}
<p
aria-hidden="true"
className={`pointer-events-none absolute right-5 bottom-5 flex items-center gap-2 text-[10px] font-medium tracking-[0.18em] text-gray-400 uppercase transition-opacity duration-500 motion-reduce:transition-none sm:right-8 sm:bottom-7 ${
touched ? "opacity-0" : "opacity-100"
}`}
>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-600" />
<span className="[@media(pointer:coarse)]:hidden">Move to bend</span>
<span className="hidden [@media(pointer:coarse)]:inline">Drag to bend</span>
</p>
</div>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/liquid-glass-hero.json