First CH Designs
PGE-05Pages

Liquid Glass Hero Background (WebGL refraction + melt)

A hero whose background is a single full-screen triangle rendered by one fragment shader: blobs of refracting glass over the gallery's dotted specimen grid. No 3D library — plain WebGL with GLSL ES 1.00, so the same shader runs on both webgl2 and webgl contexts and the dependency list stays empty. The grid and the amber diagonal band are drawn procedurally inside the shader rather than sampled from a texture, and refraction is simply re-sampling that function offset along the surface normal, with chromatic dispersion at 1.035 / 1.00 / 0.965 per channel. The blobs are circle SDFs combined with a polynomial smooth-minimum, so they neck and melt into one another as they approach, and modulating the viscosity over time makes the field breathe. The pointer acts as a sixth blob; after a pause a Lissajous virtual pointer takes over so the effect reads on touch devices. Low-end hardware is handled by a two-step resolution drop — if the mean frame time over the first 48 frames exceeds 32ms the backing store falls to 0.6x, and if it still does the loop stops and a static CSS gradient takes its place. Under prefers-reduced-motion the same shader paints one frozen frame instead of animating, and rendering also stops off-screen (IntersectionObserver) and on hidden tabs. webglcontextlost is always caught with preventDefault, and unmounting releases the GPU resources via WEBGL_lose_context. All copy, colour and composition are original; no image assets.

Added:
2026-08-14
Dependencies:
None
tags
#hero #page #webgl #glsl #shader #glass #refraction #sdf #interactive #motion #no-image #no-dependency

Preview

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

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/liquid-glass-hero.json