この記事は Zenn にも投稿しています → https://zenn.dev/satoyoshi/articles/liquid-glass-magnetic-cursor
最新の更新は Zenn 側を正としています。
AppleがLiquid Glassを出してから、あのぬるっとした透明感を見るたびに考えていた。これ、Webで作れないか。それも、ただガラスの板を置くんじゃなくて、マウスカーソルの動きに反応して液体みたいに伸び縮みするやつを。
そこで、Chrome拡張として作ってみた。
ページ上のクリックできる要素にガラスが吸い付いて、ポインタへ向かって伸びる。しかも見た目だけの飾りではなくて、ガラスが吸い付いた要素は隙間をクリックしても当たる。ごちゃごちゃした管理画面の小さいアイコンボタンも、リンクが密集したページも、狙わなくてもクリックできるようになって、カーソルの操作感が別物になった。
コードはGitHubで全部公開している。
この記事では、Liquid Glassの材質をCSSとSVGでどう作るか、「液体として破綻しない動き」をどう数式で保証するか、そして吸い付く先をどう選ぶかを書く。
Liquid Glassの材質は4層でできている
Webでそれっぽいガラスを作る要素は、分解すると4つになる。
- よく見るすりガラス(
backdrop-filter: blur() saturate()) - 縁で背景が歪む屈折。これがLiquid Glassの肝
- 輪郭に沿った細い発光リム
- ごく薄いティントと影がつくる奥行き
いちばん効くのは2の屈折で、backdrop-filter: url(#filter)にSVGのfeDisplacementMapを渡すと実現できる。仕組みは「変位マップ」だ。画像のRとGのチャンネルが「そのピクセルの背景をどれだけX/Yにずらして採取するか」を表すので、丸角矩形の縁だけが歪むマップをCanvasで生成すればいい。
// 丸角矩形のSDF(符号付き距離)から、縁に集中した変位マップを作る
const sd = sdRoundRect(px - hw + 0.5, py - hh + 0.5, hw, hh, rr);
const depth = -sd; // 縁からの深さ(内側で正)
let dx = 0, dy = 0;
if (depth > 0) {
const t = clamp(depth / thickness, 0, 1); // 0=縁 → 1=中央(平ら)
const slope = squircleSlope(t); // 縁で急、中央で0
const nrm = sdNormal(px - hw + 0.5, py - hh + 0.5, hw, hh, rr);
dx = nrm[0] * slope;
dy = nrm[1] * slope;
}
img.data[idx] = clamp(128 + dx * 127, 0, 255); // R = X変位
img.data[idx + 1] = clamp(128 + dy * 127, 0, 255); // G = Y変位
これをdata URLにしてfeImageからfeDisplacementMapへ流すと、縁だけレンズのように背景が曲がる。
意外だったのはブラーの扱いで、最終的に0.5pxまで下げた。すりガラスに寄せると下の要素が読めなくなって操作の邪魔になる。「透明なのに存在感がある」は屈折とリムに担当させて、ブラーは気配程度に残すのが一番ガラスらしかった。
注意点をひとつ。backdrop-filterにSVGフィルタを渡せるのは現状Chromiumだけ。今回はChrome拡張なので割り切った。
「液体の動き」は数式で保証する
材質ができても、動きが液体でなければただのガラス細工になる。要件は2つ。
- ポインタに向かって伸びる。磁力を感じさせる
- どんな操作でも連続に変形する。液体は瞬間移動しない
形は1本の閉パスで表現する。丸角矩形の輪郭を160点にサンプリングして、各点を外向き法線方向に持ち上げ、Catmull-Romで繋ぐ。問題は「各点をどれだけ持ち上げるか」だ。
最初の実装は「輪郭上でポインタに最も近い点」を中心にガウス分布の山を置いた。これが罠だった。カーソルが要素の中心線をまたぐ瞬間、「最も近い点」が輪郭の反対側へ跳んで、膨らみがパチッと切り替わる。使うと想像以上に気持ち悪い。
原因は「どの点を中心にするか」という離散的な選択が式の中に残っていたこと。なので選択そのものをやめて、輪郭上の各点を「ポインタへのユークリッド距離の超過分」で持ち上げる形に書き換えた。
$$
\text{bulge}i = A \exp\left(-\frac{d_i^2 - d{\min}^2}{2\sigma^2}\right) \cdot \text{prox}
$$
$d_i$は輪郭上の点$i$からポインタへの距離、$d_{\min}$はその最小値。一番近いあたりは大きく持ち上がり、遠い点はなだらかに減衰して、ポインタがどこへ動いても全体が連続に変形する。
const bestD = Math.sqrt(bestD2);
const proximity = clamp(1 - bestD / opts.bulgeR, 0, 1);
const x = clamp(bestD / opts.bulgeR, 0, 1);
// 磁力ストレッチ: 要素上では 0、中間距離でピーク、離れると 0
const amp = opts.baseBulge * proximity
+ (insideFrame ? 0 : opts.pullMax * 4 * x * (1 - x));
const twoSigma2 = 2 * opts.bulgeSigma * opts.bulgeSigma;
return pts.map((pt, i) => {
const bulge = amp * Math.exp(-(d2s[i] - bestD2) / twoSigma2);
return [pt.x + pt.nx * bulge, pt.y + pt.ny * bulge];
});
振幅の4x(1-x)も効いている。要素の上に乗っているときは静かで、中間距離で最も大きく伸びて、離れると消える。「要素がポインタへ手を伸ばしてくる」ように見えるのはこの山型のおかげだ。
要素から要素へ乗り移るときも同じ発想で、160点の対応が固定なのを利用して、点列を毎フレーム35%ずつ目標の形へ補間する。ガラスが隣の要素へ流れていくように見える。
どこに吸い付くかは、2005年の論文から借りた
ガラスが吸い付く先、つまり「いまユーザーがクリックしたい要素」はどう選ぶべきか。ここをどうするか調べていたら、2005年のHCI論文がまさにこの問題を扱っていた。bubble cursor(Grossman & Balakrishnan, CHI 2005)。ルールは「ポインタに最も近いターゲットを、ただ1個だけ選ぶ」。
単純に見えるが、この定義は密集地帯で壊れない。素朴な磁石カーソル(gravity系)は目的地までの途中にあるリンクへ次々に吸い付いて進めなくなる。bubble cursorは「2番目に近いターゲット」が現れた瞬間そちらへ切り替わるから、掴むのは常に1個。リンクだらけのページでも挙動が安定する。論文は被験者実験で、選択が有意に速くなることまで示している。20年前の論文に、いま欲しいものがそのまま書いてあった。
実装は「点と矩形の最短距離」の最小を取るだけだ。
/** 点から矩形への最短距離(内側なら 0)。 */
function pointToRect(x: number, y: number, r: DOMRect): number {
const dx = Math.max(r.left - x, 0, x - r.right);
const dy = Math.max(r.top - y, 0, y - r.bottom);
return Math.hypot(dx, dy);
}
maxRadius(120px)を超えたら何も吸い付かない。余白のクリックは普通のWebのままにしておく。テキスト入力中や文字選択中も磁石ごと停止する。
そして設計の背骨として、選択(ヒットテスト)と演出を完全に分離した。ガラスは選択結果を読むだけで、何も決めない。クリックの行き先は常に上の数式が決めるので、アニメーションがどれだけ暴れても誤クリックが構造的に起きない。おかげで開発の途中、Canvas実装からSVG実装へ見た目を丸ごと作り直したときも、クリック挙動には指一本触れずに済んだ。
デモ動画も全自動になった
冒頭のデモ動画、実は人間がマウスを動かしていない。手動で滑らかにマウスを動かすのが下手すぎたので、ヘッドレスChromiumに拡張をロードし、macOS風の偽カーソルをページに注入してベジェ曲線で動かし、CDPのスクリーンキャストで60fps録画してffmpegで合成する仕組みを作った。1コマンドで何度でも撮り直せる。
踏んだ罠を2つだけ置いておく。ブランド版Chrome 137以降は--load-extensionを黙って無視するので、PlaywrightのChromium(channel: 'chromium')を使うこと。実サイトを録画すると他社の広告が映り込むことがあり、広告は遅延ロードでクラス名も毎回変わるので、録画中に300ms間隔でiframeと広告要素を消し続ける常駐スイーパーが要る。この話は続編にする。
まとめ
- Liquid Glassの正体は屈折。blurは気配程度でよく、
feDisplacementMapが本体 - 液体の動きは「何を中心にするか」という離散選択を式から消すと連続になる
- 吸い付く先の決め方は2005年の論文(bubble cursor)から借りた
「ごちゃごちゃしたUIを、狙わずにクリックできる」は一度体験すると戻れないので、手元で試してほしい。