First CH Designs
PGE-05Pages

リキッドガラス・ヒーロー背景(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

プレビュー

Refraction Field

First CH Interactive

Light bends here.

触れた場所から画面が溶ける。First CH は、質感のある表現を軽いコードのまま実装します。

屈折
背景の方眼をレンズが曲げる
融合
近づいた塊どうしが溶け合う
追従
ポインタが6つ目の塊になる
"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