PageSourceSearch

https://inkas.org/assets/design/liquid-glass.js

js inkas.org collected 2026-10-03 00:52:32 UTC 8,695 bytes, 233 lines download raw bytes

1/*!
2 * liquid-glass.js — Apple-style liquid glass refraction for any element.
3 *
4 * Usage:
5 *   <script src="liquid-glass.js"></script>
6 *   const glass = liquidGlass(document.querySelector('.my-card'), { scale: -112 });
7 *
8 * The module owns the SVG filter, displacement map, backdrop-filter wiring,
9 * resize handling, and the frosted-blur fallback for browsers that can't do
10 * SVG-filtered backdrops (Safari, Firefox). Visual dressing (tint, inner
11 * highlight, glare, shadows) stays in your CSS — see GLASS.md for the recipe.
12 *
13 * Technique per https://aave.com/design/building-glass-for-the-web and
14 * https://github.com/rizroze/liquid-glass
15 */
16(function (global) {
17  "use strict";
18
19  const SVG_NS = "http://www.w3.org/2000/svg";
20  let uid = 0;
21  let svgDefs = null;
22
23  // Chromium can apply SVG filters via backdrop-filter; Safari and Firefox
24  // silently no-op, so they get the frosted fallback instead.
25  const supported = (() => {
26    const ua = navigator.userAgent;
27    const isSafari = /Safari/.test(ua) && !/Chrome|Chromium|Edg/.test(ua);
28    const isFirefox = /Firefox/.test(ua);
29    if (isSafari || isFirefox) return false;
30    if (!CSS.supports("backdrop-filter", "url(#lg)")) return false;
31    try {
32      const c = document.createElement("canvas");
33      c.width = c.height = 4;
34      c.getContext("2d").getImageData(0, 0, 1, 1);
35      return true;
36    } catch (_) {
37      return false;
38    }
39  })();
40
41  function ensureDefs() {
42    if (svgDefs) return svgDefs;
43    const svg = document.createElementNS(SVG_NS, "svg");
44    // width/height 0 keeps it renderable (display:none would break feImage)
45    svg.setAttribute("width", "0");
46    svg.setAttribute("height", "0");
47    svg.setAttribute("aria-hidden", "true");
48    svg.style.position = "absolute";
49    svgDefs = document.createElementNS(SVG_NS, "defs");
50    svg.appendChild(svgDefs);
51    document.body.appendChild(svg);
52    return svgDefs;
53  }
54
55  // Displacement map, gradient-difference method: a red left->right ramp
56  // encodes X displacement, a blue top->bottom ramp encodes Y ("difference"
57  // keeps both since the channels are disjoint). A blurred, inset 50%-gray
58  // rounded rect neutralizes the interior, confining the refraction bulge to
59  // an edge band whose curvature is set by the blur radius.
60  function makeMap(w, h, radius, border, mapBlur) {
61    const canvas = document.createElement("canvas");
62    canvas.width = w;
63    canvas.height = h;
64    const ctx = canvas.getContext("2d");
65
66    const gx = ctx.createLinearGradient(0, 0, w, 0);
67    gx.addColorStop(0, "rgb(0,0,0)");
68    gx.addColorStop(1, "rgb(255,0,0)");
69    ctx.fillStyle = gx;
70    ctx.fillRect(0, 0, w, h);
71
72    const gy = ctx.createLinearGradient(0, 0, 0, h);
73    gy.addColorStop(0, "rgb(0,0,0)");
74    gy.addColorStop(1, "rgb(0,0,255)");
75    ctx.globalCompositeOperation = "difference";
76    ctx.fillStyle = gy;
77    ctx.fillRect(0, 0, w, h);
78
79    ctx.globalCompositeOperation = "source-over";
80    const inset = border * Math.min(w, h);
81    ctx.filter = "blur(" + mapBlur + "px)";
82    ctx.fillStyle = "rgba(128,128,128,0.93)";
83    ctx.beginPath();
84    ctx.roundRect(inset, inset, w - inset * 2, h - inset * 2,
85                  Math.max(radius - inset, 2));
86    ctx.fill();
87    ctx.filter = "none";
88    return canvas.toDataURL();
89  }
90
91  // Three displacement passes at staggered scales (R strongest), channels
92  // isolated with feColorMatrix and recombined with screen blends — the
93  // faint prism fringe at the rim.
94  function buildFilter(id, scales) {
95    const filter = document.createElementNS(SVG_NS, "filter");
96    filter.setAttribute("id", id);
97    filter.setAttribute("x", "0");
98    filter.setAttribute("y", "0");
99    filter.setAttribute("width", "100%");
100    filter.setAttribute("height", "100%");
101    // Load-bearing: filters default to linearRGB, which re-maps the map's
102    // neutral gray 128 to ~0.216 and injects a constant phantom displacement.
103    filter.setAttribute("color-interpolation-filters", "sRGB");
104
105    const feImage = document.createElementNS(SVG_NS, "feImage");
106    feImage.setAttribute("x", "0");
107    feImage.setAttribute("y", "0");
108    feImage.setAttribute("result", "map");
109    feImage.setAttribute("preserveAspectRatio", "none");
110    filter.appendChild(feImage);
111
112    const keep = [
113      "1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0",
114      "0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0",
115      "0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0",
116    ];
117    const channels = [];
118    for (let i = 0; i < 3; i++) {
119      const disp = document.createElementNS(SVG_NS, "feDisplacementMap");
120      disp.setAttribute("in", "SourceGraphic");
121      disp.setAttribute("in2", "map");
122      disp.setAttribute("scale", scales[i]);
123      disp.setAttribute("xChannelSelector", "R");
124      disp.setAttribute("yChannelSelector", "B");
125      disp.setAttribute("result", "d" + i);
126      filter.appendChild(disp);
127
128      const cm = document.createElementNS(SVG_NS, "feColorMatrix");
129      cm.setAttribute("in", "d" + i);
130      cm.setAttribute("type", "matrix");
131      cm.setAttribute("values", keep[i]);
132      cm.setAttribute("result", "c" + i);
133      filter.appendChild(cm);
134      channels.push("c" + i);
135    }
136
137    const blend1 = document.createElementNS(SVG_NS, "feBlend");
138    blend1.setAttribute("in", channels[0]);
139    blend1.setAttribute("in2", channels[1]);
140    blend1.setAttribute("mode", "screen");
141    blend1.setAttribute("result", "c01");
142    filter.appendChild(blend1);
143
144    const blend2 = document.createElementNS(SVG_NS, "feBlend");
145    blend2.setAttribute("in", "c01");
146    blend2.setAttribute("in2", channels[2]);
147    blend2.setAttribute("mode", "screen");
148    filter.appendChild(blend2);
149
150    ensureDefs().appendChild(filter);
151    return { filter, feImage };
152  }
153
154  function resolveRadius(el, w, h, override) {
155    if (override != null) return override;
156    const raw = getComputedStyle(el).borderTopLeftRadius || "0px";
157    const v = parseFloat(raw) || 0;
158    return raw.trim().endsWith("%") ? (v / 100) * Math.min(w, h) : v;
159  }
160
161  /**
162   * Apply liquid glass to an element.
163   *
164   * @param {Element} el
165   * @param {Object} [opts]
166   * @param {number} [opts.scale=-112]     Displacement strength (negative = magnifying bulge).
167   * @param {number} [opts.chroma=6]       Per-channel scale stagger; 0 disables the prism fringe.
168   * @param {number} [opts.border=0.07]    Neutral inset as a fraction of the smaller side.
169   * @param {number} [opts.mapBlur=12]     Edge-curvature softness (px) of the map's gray inset.
170   * @param {number} [opts.blur=3]         Backdrop blur (px) behind the glass interior.
171   * @param {number} [opts.saturate=1.5]   Backdrop saturation boost.
172   * @param {number} [opts.radius]         Corner radius override (px); default reads border-radius.
173   * @param {number} [opts.fallbackBlur=16] Frosted blur (px) where refraction is unsupported.
174   * @returns {{supported: boolean, refresh: Function, destroy: Function}}
175   */
176  function liquidGlass(el, opts) {
177    const o = Object.assign(
178      { scale: -112, chroma: 6, border: 0.07, mapBlur: 12,
179        blur: 3, saturate: 1.5, radius: null, fallbackBlur: 16 },
180      opts
181    );
182
183    if (!supported) {
184      const frosted = "blur(" + o.fallbackBlur + "px) saturate(" + o.saturate + ")";
185      el.style.backdropFilter = frosted;
186      el.style.webkitBackdropFilter = frosted;
187      el.classList.add("lg-fallback");
188      return { supported: false, refresh: function () {}, destroy: function () {
189        el.style.backdropFilter = "";
190        el.style.webkitBackdropFilter = "";
191        el.classList.remove("lg-fallback");
192      } };
193    }
194
195    const id = "lg-filter-" + (++uid);
196    const scales = [o.scale, o.scale + o.chroma, o.scale + 2 * o.chroma];
197    const parts = buildFilter(id, scales);
198
199    function refresh() {
200      const w = el.offsetWidth;
201      const h = el.offsetHeight;
202      if (!w || !h) return;
203      const radius = resolveRadius(el, w, h, o.radius);
204      parts.feImage.setAttribute("href", makeMap(w, h, radius, o.border, o.mapBlur));
205      parts.feImage.setAttribute("width", w);
206      parts.feImage.setAttribute("height", h);
207    }
208
209    refresh();
210    el.style.backdropFilter =
211      "url(#" + id + ") blur(" + o.blur + "px) saturate(" + o.saturate + ")";
212
213    let timer = null;
214    const ro = new ResizeObserver(function () {
215      clearTimeout(timer);
216      timer = setTimeout(refresh, 120);
217    });
218    ro.observe(el);
219
220    return {
221      supported: true,
222      refresh: refresh,
223      destroy: function () {
224        ro.disconnect();
225        clearTimeout(timer);
226        parts.filter.remove();
227        el.style.backdropFilter = "";
228      },
229    };
230  }
231
232  global.liquidGlass = liquidGlass;
233})(window);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.