アニメーション

CSSとVFX-JSで作る、マウスに追従する液体ガラスエフェクト

投稿日2026/10/05

更新日2026/9/26

マウスの動きに合わせて液体のようなガラスが追従し、背景の画像やテキストをリアルタイムに屈折させるエフェクトです。

VFX-JSとシェーダーを組み合わせることで、ガラス越しに見たような歪みや光の反射、色の分散などを表現しています。

周囲には小さな気泡が浮かぶように動き、クリックするとメインの形状が球体・キューブ・リングへ切り替わります。

ヒーロービジュアルやポートフォリオなど、マウス操作に反応する印象的な演出として活用できます。

Code コード

<div id="kumonosu-app" class="kumonosu-wrapper">
	<section class="kumonosu-hero">
		<img alt="KUMONOSUの背景画像" src="https://kumonosu.net/wp-content/uploads/2026/09/260926.jpg.webp" />
		<h1 class="kumonosu-hero-title">KUMONOSU</h1>
	</section>
</div>
.kumonosu-wrapper {
	--bg: #fbfbfd;
	--fg: #1d1d1f;
	position: fixed;
	inset: 0;
	margin: 0;
	width: 100%;
	height: 100%;
	overflow: hidden;
	background: var(--bg);
	color: var(--fg);
	font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display",
		"SF Pro Text", "Helvetica Neue", sans-serif;
	-webkit-font-smoothing: antialiased;
	text-rendering: optimizeLegibility;
}
.kumonosu-hero {
	position: relative;
	width: 100%;
	height: 100vh;
	height: 100dvh;
	overflow: hidden;
	background: black;
}
.kumonosu-hero img {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
}
.kumonosu-hero img {
	background: black;
	opacity: 0.3;
}
.kumonosu-hero-title {
	position: absolute;
	inset: 0;
	display: grid;
	place-items: center;
	margin: 0;
	padding-left: 0.12em;
	color: #fff;
	font-size: clamp(2rem, 9vw, 9rem);
	font-weight: 600;
	letter-spacing: 0.12em;
	line-height: 1;
	text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
	pointer-events: none;
}
const PARAMS = {
	sphereR: 0.12,
	bubbleCount: 8,
	bubbleRadiusMin: 0.03,
	bubbleRadiusMax: 0.07,
	bubbleSpeed: 0.7,
	mouseSmoothing: 0.05,
};
const postEffectShader = `
precision highp float;
uniform sampler2D src;
uniform vec2 resolution;
uniform vec2 offset;
uniform vec2 mouse;
uniform vec2 lag;
uniform float time;
uniform float clickTime;
uniform int clickCount;
out vec4 outColor;

const float SPHERE_R = ${PARAMS.sphereR.toFixed(4)};

const float DISP = 0.025;
const int   DISP_STEPS = 12;
const float DISP_LO = 0.0;
const float DISP_HI = 1.0;

const float SCATTER = 0.03;

const int N_BUBBLES = ${PARAMS.bubbleCount};
const float BUBBLE_SMOOTH = 0.025;
uniform float bubbleData[${PARAMS.bubbleCount * 4}];

const vec3 ABSORB = vec3(2.0, 1.2, 1.0) * 3.;

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

vec2 hash22(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.xx + p3.yz) * p3.zy) * 2.0 - 1.0;
}

mat2 rot(float t) {
  float c = cos(t), s = sin(t);
  return mat2(c, -s, s, c);
}

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdBox(vec3 p, vec3 b, float r) {
  vec3 q = abs(p) - b + r;
  return length(max(q,0.0)) + min(max(q.x,max(q.y,q.z)),0.0) - r;
}

float sdRing(vec3 p, vec2 r) {
  float s = length(p.xy) - r.x;
  return length(vec2(s, p.z)) - r.y;
}

float map(vec3 p, vec3 c) {
  vec3 q = p - c;

  float tt = clickTime * 5.0;
  float bounce = exp(-tt) * sin(tt) * 5. + (1. - exp(-tt));
  float s = bounce * 0.5 + 0.5;
  q /= s;

  q.xz *= rot(exp(-clickTime*3.0) * 8.);

  vec3 sp = q;
  sp.y += sin(sp.z * 29. + time * 6.5) * 0.01;
  sp.z += sin(sp.x * 23. + sp.y * 11. + time * 7.) * 0.01;
  sp.xy *= rot(time*1.3);
  sp.xz *= rot(time*1.1);

  float d ;
  int objType = clickCount % 3;
  if (objType == 0) {
    d = sdSphere(sp, SPHERE_R);
  } else if (objType == 1) {
    d = sdBox(sp, vec3(SPHERE_R*0.8), 0.01);
  } else {
    d = sdRing(sp, vec2(SPHERE_R*1.1, 0.015));
  }

  for (int i = 0; i < N_BUBBLES; i++) {
    int b = i * 4;
    vec3 bPos = vec3(bubbleData[b], bubbleData[b+1], bubbleData[b+2]);
    float r = bubbleData[b+3];
    d = smin(d, sdSphere(q - bPos, max(r, 0.001)), BUBBLE_SMOOTH);
  }

  return d * s;
}

vec3 calcNormal(vec3 p, vec3 c) {
  vec2 e = vec2(0.001, 0.0);
  return normalize(vec3(
    map(p + e.xyy, c) - map(p - e.xyy, c),
    map(p + e.yxy, c) - map(p - e.yxy, c),
    map(p + e.yyx, c) - map(p - e.yyx, c)
  ));
}

vec3 spectrum(float x) {
  return clamp(vec3(
    1.5 - abs(4.0 * x - 1.0),
    1.5 - abs(4.0 * x - 2.0),
    1.5 - abs(4.0 * x - 3.0)
  ), 0.0, 1.0);
}

vec4 getSrc(vec2 uv) {
  vec4 c = texture(src, uv);
  return mix(vec4(1), c, c.a);
}

void main() {
  vec2 uv = (gl_FragCoord.xy - offset) / resolution;
  float aspect = resolution.y / resolution.x;

  vec2 p = (uv - 0.5) * vec2(1.0, aspect);
  vec2 mp = ((mouse + lag) / resolution - 0.5) * vec2(1.0, aspect);

  vec3 ro = vec3(0.0, 0.0, -2.0);
  float focal = 2.0;
  vec3 rd = normalize(vec3(p, focal));

  vec3 c = vec3(mp, 0.0);

  vec3 firstN = vec3(0.0);
  vec3 lastN = vec3(0.0);
  int hitCount = 0;

  float thickness = 0.0;
  float tEntry = 0.0;
  float t = 0.0;
  bool inside = false;
  for (int i = 0; i < 50; i++) {
    if (t > 10.0) break;

    vec3 pos = ro + rd * t;
    float d = map(pos, c);

    float step = inside ? -d : d;
    if (step < 3e-4) {
      vec3 n = calcNormal(pos, c);
      if (hitCount == 0) firstN = n;
      lastN = n;
      if (!inside) {
        tEntry = t;
      } else {
        thickness += t - tEntry;
      }

      hitCount++;
      if (hitCount >= 4) { break; }

      inside = !inside;
      t += 0.01;
    } else {
      t += step;
    }
  }

  if (hitCount > 0) {
    vec2 baseDisp = -(firstN.xy + lastN.xy) * 0.5 * DISP;

    float NdotR = max(dot(firstN, -rd), 0.0);
    float scatter = pow((1.0 - NdotR), 2.0) * SCATTER;

    vec3 acc = vec3(0.0);
    vec3 wsum = vec3(0.0);
    for (int i = 0; i < DISP_STEPS; i++) {
      float wl = float(i) / float(DISP_STEPS - 1);
      float k = mix(DISP_LO, DISP_HI, wl) * (1.3 + float(hitCount) * 0.2);
      vec2 h = hash22(uv * 1000.0 + float(i) * 7.13 + time) * scatter;
      vec3 w = spectrum(wl);
      acc += getSrc(uv + baseDisp * k + h).rgb * w;
      wsum += w;
    }
    vec3 col = acc / wsum * 0.99;
    col -= float(hitCount) * 0.05;

    col += 0.1;

    float fres = pow(1.0 - NdotR, 5.0);
    col *= 1. + fres;

    float f2 = 1. - pow(NdotR, 3.0);
    col *= mix(vec3(1), exp(-ABSORB * thickness), f2);
    col *= 1. + f2;

    vec3 ld = normalize(vec3(0.5, 0.9, -0.3));
    float spec = pow(max(dot(reflect(-ld, firstN), -rd), 0.0), 200.0);
    col += spec * 30.;

    ld = normalize(vec3(-0.9, 0.4, -0.3));
    spec = pow(max(dot(reflect(-ld, firstN), -rd), 0.0), 300.0);
    col += spec * 3.;

    ld = normalize(vec3(-0.1, -0.9, -0.1));
    spec = pow(max(dot(reflect(-ld, firstN), -rd), 0.0), 30.0);
    col += spec * 0.5;

    col = min(col, 1.);
    col = 1. - abs(col + fres * .5 - 1.);

    outColor = vec4(col, 1.0);
  } else {
    outColor = getSrc(uv);
  }
}

`;
window.addEventListener("load", async () => {
	const {
		VFX
	} = await import("https://esm.sh/@vfx-js/core");
	const app = document.getElementById("kumonosu-app");
	const N = PARAMS.bubbleCount;
	const fract = (x) => x - Math.floor(x);
	const rot2d = (x, y, t) => {
		const c = Math.cos(t),
			s = Math.sin(t);
		return [x * c - y * s, x * s + y * c];
	};
	const p0 = {
		x: 0,
		y: 0
	};
	const p1 = {
		x: 0,
		y: 0
	};
	const p2 = {
		x: 0,
		y: 0
	};
	let hasMouse = false;
	let centerBlend = 1.0;
	window.addEventListener("pointermove", (e) => {
		p0.x = e.clientX;
		p0.y = window.innerHeight - e.clientY;
		hasMouse = true;
	});
	let lastClickTime = performance.now() / 1000;
	let clickCount = 0;
	window.addEventListener("pointerdown", () => {
		lastClickTime = performance.now() / 1000;
		clickCount++;
	});
	const bubbles = new Float32Array(N * 4);
	const t0 = performance.now() / 1000;

	function tick() {
		const time = performance.now() / 1000 - t0;
		const sm = PARAMS.mouseSmoothing;
		p1.x += (p0.x - p1.x) * sm;
		p1.y += (p0.y - p1.y) * sm;
		p2.x += (p1.x - p2.x) * sm;
		p2.y += (p1.y - p2.y) * sm;
		if (hasMouse) {
			centerBlend *= 0.95;
		}
		for (let i = 0; i < N; i++) {
			const life = fract(time * PARAMS.bubbleSpeed + i / N);
			const orbitR = PARAMS.sphereR * (0.3 + life * 0.8);
			const orbitAngle = time * (0.8 + fract(i * 0.618) * 0.7) + i * 1.256;
			let bx = Math.cos(orbitAngle) * orbitR;
			let by = 0;
			let bz = Math.sin(orbitAngle) * orbitR;
			[bx, by] = rot2d(bx, by, i * 2.3);
			[by, bz] = rot2d(by, bz, i * 1.8);
			by += life * 0.1;
			bx += Math.sin(time * 2.7 + i * 4.1) * 0.008 * life;
			bz += Math.cos(time * 3.1 + i * 3.7) * 0.008 * life;
			const w = window.innerWidth,
				h = window.innerHeight;
			bx += ((p2.x - p1.x) / w) * (h / w);
			by += (p2.y - p1.y) / h;
			const range = PARAMS.bubbleRadiusMax - PARAMS.bubbleRadiusMin;
			const maxR = PARAMS.bubbleRadiusMin + range * fract(i * 0.618);
			const j = i * 4;
			bubbles[j] = bx;
			bubbles[j + 1] = by;
			bubbles[j + 2] = bz;
			bubbles[j + 3] = maxR * Math.sin(life * Math.PI);
		}
		requestAnimationFrame(tick);
	}
	tick();
	const vfx = new VFX({
		postEffect: {
			shader: postEffectShader,
			uniforms: {
				lag: () => {
					const lx = (p1.x - p0.x) * devicePixelRatio;
					const ly = (p1.y - p0.y) * devicePixelRatio;
					const cx = window.innerWidth / 2 * devicePixelRatio * centerBlend;
					const cy = window.innerHeight / 2 * devicePixelRatio * centerBlend;
					return [cx + lx, cy + ly];
				},
				clickTime: () => performance.now() / 1000 - lastClickTime,
				clickCount: () => clickCount,
				bubbleData: () => bubbles,
			},
		},
	});
	await vfx.addHTML(app, {
		shader: "none"
	});
	vfx.play();
});

Explanation 詳しい説明

基本構造

画面いっぱいに背景画像と「KUMONOSU」のテキストを配置し、その上からVFX-JSのポストエフェクトを適用しています。

ガラスそのものをHTML要素として配置しているのではなく、GLSLで記述したシェーダーによって画面上に形状を生成し、その部分の背景を屈折させる仕組みです。

JavaScriptではマウス座標やクリック状態、小さな気泡の位置などを計算し、その値をシェーダーへ渡すことでリアルタイムに描画内容を変化させています。

液体ガラスの表現

メインとなるガラスには、SDF(Signed Distance Function)を利用した形状表現が使われています。

初期状態では球体ですが、クリックするたびに球体・角の丸いキューブ・リングの3種類が順番に切り替わります。

さらに形状そのものにも時間による細かな変形や回転を加えることで、完全に固定されたオブジェクトではなく、液体のように揺らめく動きを作っています。

クリックした瞬間には拡大・収縮するような動きも加わり、形状が切り替わる際の変化を滑らかに見せています。

背景の屈折

ガラス部分では、表面の法線をもとに背景を読み取るUV座標をずらしています。

これにより、ガラスの向こう側にある背景画像や「KUMONOSU」の文字が歪んで見え、透明なレンズを通して覗いているような表現になります。

単純に背景を拡大するだけではなく、複数回サンプリングして色ごとにわずかなズレを加えているため、ガラスの縁には色が分離したような表現も現れます。

さらにフレネル効果やスペキュラーによる光の反射、厚みに応じた色の変化などを組み合わせ、透明な物体らしい質感を作っています。

マウスへの追従

pointermoveで取得したマウス座標を、そのままガラスの位置に使用するのではなく、複数段階で補間しています。

そのため、カーソルへ完全に固定されるのではなく、少し遅れて追いかけてくるような柔らかい動きになります。

mouseSmoothingの値を変更することで、この追従感を調整できます。

値を小さくするとゆっくり追従し、大きくするとカーソルに素早く反応するようになります。

気泡のアニメーション

メインのガラスだけでなく、その周囲には複数の小さな気泡が生成されています。

それぞれの気泡は時間に応じてメイン形状の周囲を移動しながら、徐々に大きくなって消えるような動きを繰り返します。

気泡とメイン形状はsmin()によって滑らかに結合されるため、近づいた部分が液体同士のようにつながって見えるのも特徴です。

カスタム方法

コード冒頭のPARAMSから、エフェクトの基本的な見た目や動きを変更できます。

sphereRではメインとなるガラスの大きさ、bubbleCountでは周囲に表示する気泡の数を設定できます。

bubbleRadiusMinとbubbleRadiusMaxを変更すると気泡の最小・最大サイズを、bubbleSpeedでは気泡が変化する速度を調整できます。

mouseSmoothingはマウスへの追従速度です。液体らしいゆっくりとした動きを強調したい場合は小さく、レスポンスを速くしたい場合は大きくします。

ガラスによる背景の歪みはシェーダー内のDISP、色の散らばりはSCATTERなどから調整できます。

また、sdSphere・sdBox・sdRingの部分を変更すれば、クリック時に切り替わる形状そのものをカスタマイズすることもできます。

注意点

このエフェクトはVFX-JSに加えてGLSLシェーダーを使用しているため、一般的なCSSアニメーションと比べるとGPUへの負荷が大きくなります。

特に気泡の数やレイマーチングの計算回数、背景をサンプリングする回数を増やすほど処理負荷も高くなります。スマートフォンを含めて使用する場合は、実機で描画速度を確認しながら調整するのがおすすめです。

また、VFX-JSは外部モジュールとして読み込んでいるため、ライブラリを取得できない環境ではエフェクトが動作しません。

背景画像やテキスト自体は通常のHTMLとして配置されているため、エフェクトが利用できない場合でも元のヒーロービジュアルは表示できる構成になっています。