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.