@mizuui/liquid-glass-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # mizuui-liquid-glass-core
2
+
3
+ The rendering engine behind the liquid glass effect. No framework, no build step — plain ESM.
4
+
5
+ It generates a displacement map for a rounded rectangle and feeds it to an SVG
6
+ `feDisplacementMap` that runs inside `backdrop-filter`, so the content behind the
7
+ element is genuinely refracted through a bevel. There is no blur by default: this
8
+ is glass, not frost.
9
+
10
+ ```bash
11
+ npm install mizuui-liquid-glass-core
12
+ ```
13
+
14
+ ```js
15
+ import { createLiquidGlass } from "mizuui-liquid-glass-core";
16
+
17
+ const glass = createLiquidGlass(document.querySelector("#panel"), {
18
+ radius: 28,
19
+ depth: 12,
20
+ strength: 70,
21
+ });
22
+
23
+ glass.update({ strength: 100 });
24
+ glass.destroy();
25
+ ```
26
+
27
+ ## Options
28
+
29
+ | Option | Default | Description |
30
+ | --------------------- | ------- | ------------------------------------------------------- |
31
+ | `radius` | `24` | Corner radius in px. |
32
+ | `depth` | `10` | Thickness of the refracting bevel in px. |
33
+ | `strength` | `60` | Maximum backdrop displacement in px. |
34
+ | `falloff` | `2` | Bevel curve. Higher pushes the bend towards the edge. |
35
+ | `chromaticAberration` | `3` | Per-channel offset in px. |
36
+ | `blur` | `0` | Backdrop blur. Anything above 0 starts looking frosted. |
37
+ | `saturation` | `1.6` | Backdrop saturation multiplier. |
38
+ | `brightness` | `1.04` | Backdrop brightness multiplier. |
39
+ | `specular` | `1` | Specular rim intensity, `0` disables it. |
40
+ | `resolution` | `512` | Max pixel size of the generated displacement map. |
41
+ | `fallbackBlur` | `8` | Blur used where SVG backdrop filters are unsupported. |
42
+
43
+ The instance resizes itself via `ResizeObserver`; call `refresh()` if you move it
44
+ in a way that observer cannot see.
45
+
46
+ ## Browser support
47
+
48
+ `backdrop-filter: url(#filter)` currently works in Chromium. Safari and Firefox
49
+ fall back to `fallbackBlur` plus the specular rim.
package/index.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ export interface LiquidGlassOptions {
2
+ radius?: number
3
+ depth?: number
4
+ strength?: number
5
+ falloff?: number
6
+ chromaticAberration?: number
7
+ blur?: number
8
+ saturation?: number
9
+ brightness?: number
10
+ specular?: number
11
+ resolution?: number
12
+ fallbackBlur?: number
13
+ }
14
+
15
+ export interface LiquidGlassInstance {
16
+ readonly options: Required<LiquidGlassOptions>
17
+ update(options: LiquidGlassOptions): void
18
+ refresh(): void
19
+ destroy(): void
20
+ }
21
+
22
+ export declare const defaultOptions: Required<LiquidGlassOptions>
23
+
24
+ export declare function createLiquidGlass(
25
+ element: HTMLElement,
26
+ options?: LiquidGlassOptions,
27
+ ): LiquidGlassInstance
28
+
29
+ export declare function createDisplacementMap(options: {
30
+ width: number
31
+ height: number
32
+ radius?: number
33
+ depth?: number
34
+ falloff?: number
35
+ resolution?: number
36
+ }): { href: string; width: number; height: number }
37
+
38
+ export declare function injectStyles(): void
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@mizuui/liquid-glass-core",
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic liquid glass (real backdrop refraction, not frosted blur).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "main": "./src/index.js",
9
+ "module": "./src/index.js",
10
+ "types": "./index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./index.d.ts",
14
+ "import": "./src/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "src",
19
+ "index.d.ts",
20
+ "README.md"
21
+ ],
22
+ "keywords": [
23
+ "liquid-glass",
24
+ "glassmorphism",
25
+ "refraction",
26
+ "backdrop-filter",
27
+ "svg-filter"
28
+ ]
29
+ }
@@ -0,0 +1,92 @@
1
+ const clamp = (value, min, max) => (value < min ? min : value > max ? max : value)
2
+
3
+ /**
4
+ * Signed distance (negative = inside) to a rounded rectangle centred on the
5
+ * origin, plus the outward-pointing unit normal at that point.
6
+ */
7
+ function roundedRectSDF(px, py, halfWidth, halfHeight, radius) {
8
+ const qx = Math.abs(px) - (halfWidth - radius)
9
+ const qy = Math.abs(py) - (halfHeight - radius)
10
+ const mx = Math.max(qx, 0)
11
+ const my = Math.max(qy, 0)
12
+ const len = Math.hypot(mx, my)
13
+ const distance = Math.min(Math.max(qx, qy), 0) + len - radius
14
+
15
+ const sx = px < 0 ? -1 : 1
16
+ const sy = py < 0 ? -1 : 1
17
+
18
+ let nx
19
+ let ny
20
+ if (len > 0) {
21
+ nx = (sx * mx) / len
22
+ ny = (sy * my) / len
23
+ } else if (qx > qy) {
24
+ nx = sx
25
+ ny = 0
26
+ } else {
27
+ nx = 0
28
+ ny = sy
29
+ }
30
+
31
+ return { distance, nx, ny }
32
+ }
33
+
34
+ /**
35
+ * Renders the refraction map consumed by `feDisplacementMap`.
36
+ *
37
+ * Red encodes the horizontal offset, green the vertical one, both biased around
38
+ * 128 (= no displacement). The offset ramps up towards the edges of the rounded
39
+ * rectangle so the backdrop is bent like it would be by a thick glass bevel.
40
+ *
41
+ * @returns {{ href: string, width: number, height: number }}
42
+ */
43
+ export function createDisplacementMap({
44
+ width,
45
+ height,
46
+ radius = 24,
47
+ depth = 8,
48
+ falloff = 2,
49
+ resolution = 512,
50
+ }) {
51
+ const aspectScale = Math.min(1, resolution / Math.max(width, height, 1))
52
+ const w = Math.max(1, Math.round(width * aspectScale))
53
+ const h = Math.max(1, Math.round(height * aspectScale))
54
+
55
+ const canvas = document.createElement('canvas')
56
+ canvas.width = w
57
+ canvas.height = h
58
+
59
+ const ctx = canvas.getContext('2d')
60
+ const image = ctx.createImageData(w, h)
61
+ const data = image.data
62
+
63
+ const halfW = w / 2
64
+ const halfH = h / 2
65
+ const r = clamp(radius * aspectScale, 0, Math.min(halfW, halfH))
66
+ const edge = clamp(depth * aspectScale, 0.5, Math.min(halfW, halfH))
67
+ const curve = Math.max(0.1, falloff)
68
+
69
+ for (let y = 0; y < h; y++) {
70
+ for (let x = 0; x < w; x++) {
71
+ const px = x + 0.5 - halfW
72
+ const py = y + 0.5 - halfH
73
+ const { distance, nx, ny } = roundedRectSDF(px, py, halfW, halfH, r)
74
+
75
+ let amount = 0
76
+ if (distance < 0) {
77
+ const inset = -distance
78
+ amount = Math.pow(1 - clamp(inset / edge, 0, 1), curve)
79
+ }
80
+
81
+ const i = (y * w + x) * 4
82
+ data[i] = clamp(Math.round(128 + nx * amount * 127), 0, 255)
83
+ data[i + 1] = clamp(Math.round(128 + ny * amount * 127), 0, 255)
84
+ data[i + 2] = 128
85
+ data[i + 3] = 255
86
+ }
87
+ }
88
+
89
+ ctx.putImageData(image, 0, 0)
90
+
91
+ return { href: canvas.toDataURL(), width: w, height: h }
92
+ }
package/src/filter.js ADDED
@@ -0,0 +1,136 @@
1
+ const SVG_NS = 'http://www.w3.org/2000/svg'
2
+ const XLINK_NS = 'http://www.w3.org/1999/xlink'
3
+
4
+ let defsHost = null
5
+
6
+ function getHost() {
7
+ if (defsHost && defsHost.isConnected) return defsHost
8
+
9
+ defsHost = document.createElementNS(SVG_NS, 'svg')
10
+ defsHost.setAttribute('aria-hidden', 'true')
11
+ defsHost.setAttribute('focusable', 'false')
12
+ defsHost.dataset.liquidGlass = 'filters'
13
+ defsHost.style.cssText =
14
+ 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;'
15
+ document.body.appendChild(defsHost)
16
+
17
+ return defsHost
18
+ }
19
+
20
+ function el(name, attrs) {
21
+ const node = document.createElementNS(SVG_NS, name)
22
+ for (const key in attrs) node.setAttribute(key, attrs[key])
23
+ return node
24
+ }
25
+
26
+ /** Keeps only one colour channel, optionally discarding alpha so passes can be summed. */
27
+ function channelMatrix(channel, keepAlpha) {
28
+ const rows = {
29
+ r: '1 0 0 0 0 0 0 0 0 0 0 0 0 0 0',
30
+ g: '0 0 0 0 0 0 1 0 0 0 0 0 0 0 0',
31
+ b: '0 0 0 0 0 0 0 0 0 0 0 0 1 0 0',
32
+ }
33
+ return `${rows[channel]} 0 0 0 ${keepAlpha ? 1 : 0} 0`
34
+ }
35
+
36
+ /**
37
+ * Creates an SVG filter that refracts whatever is behind the element.
38
+ * Returns a handle used by the glass controller.
39
+ */
40
+ export function createGlassFilter(id) {
41
+ const filter = el('filter', {
42
+ id,
43
+ 'color-interpolation-filters': 'sRGB',
44
+ x: '0',
45
+ y: '0',
46
+ width: '100%',
47
+ height: '100%',
48
+ })
49
+ getHost().appendChild(filter)
50
+
51
+ return {
52
+ url: `url(#${id})`,
53
+
54
+ update({ map, scale, aberration, blur }) {
55
+ filter.replaceChildren()
56
+
57
+ const image = el('feImage', {
58
+ result: 'map',
59
+ preserveAspectRatio: 'none',
60
+ x: '0',
61
+ y: '0',
62
+ width: String(map.displayWidth),
63
+ height: String(map.displayHeight),
64
+ })
65
+ image.setAttribute('href', map.href)
66
+ image.setAttributeNS(XLINK_NS, 'xlink:href', map.href)
67
+ filter.appendChild(image)
68
+
69
+ const displace = (scaleValue, result) =>
70
+ el('feDisplacementMap', {
71
+ in: 'SourceGraphic',
72
+ in2: 'map',
73
+ scale: String(scaleValue),
74
+ xChannelSelector: 'R',
75
+ yChannelSelector: 'G',
76
+ result,
77
+ })
78
+
79
+ let last = 'refracted'
80
+
81
+ if (aberration > 0) {
82
+ const channels = [
83
+ ['r', scale + aberration],
84
+ ['g', scale],
85
+ ['b', scale - aberration],
86
+ ]
87
+
88
+ channels.forEach(([channel, channelScale], index) => {
89
+ filter.appendChild(displace(channelScale, `pass-${channel}`))
90
+ filter.appendChild(
91
+ el('feColorMatrix', {
92
+ in: `pass-${channel}`,
93
+ type: 'matrix',
94
+ values: channelMatrix(channel, index === 0),
95
+ result: `channel-${channel}`,
96
+ }),
97
+ )
98
+ })
99
+
100
+ filter.appendChild(
101
+ el('feComposite', {
102
+ in: 'channel-r',
103
+ in2: 'channel-g',
104
+ operator: 'arithmetic',
105
+ k2: '1',
106
+ k3: '1',
107
+ result: 'rg',
108
+ }),
109
+ )
110
+ filter.appendChild(
111
+ el('feComposite', {
112
+ in: 'rg',
113
+ in2: 'channel-b',
114
+ operator: 'arithmetic',
115
+ k2: '1',
116
+ k3: '1',
117
+ result: 'refracted',
118
+ }),
119
+ )
120
+ } else {
121
+ filter.appendChild(displace(scale, 'refracted'))
122
+ }
123
+
124
+ if (blur > 0) {
125
+ filter.appendChild(
126
+ el('feGaussianBlur', { in: last, stdDeviation: String(blur), result: 'blurred' }),
127
+ )
128
+ last = 'blurred'
129
+ }
130
+ },
131
+
132
+ destroy() {
133
+ filter.remove()
134
+ },
135
+ }
136
+ }
package/src/glass.js ADDED
@@ -0,0 +1,137 @@
1
+ import { createDisplacementMap } from './displacement.js'
2
+ import { createGlassFilter } from './filter.js'
3
+ import { injectStyles } from './styles.js'
4
+
5
+ export const defaultOptions = {
6
+ /** Corner radius in px. Keep in sync with any border-radius you set yourself. */
7
+ radius: 30,
8
+ /** Thickness of the refracting bevel in px. */
9
+ depth: 35,
10
+ /** Maximum backdrop displacement in px. This is the strength of the lensing. */
11
+ strength: 120,
12
+ /** Falloff curve of the bevel. Higher = the bend is pushed closer to the edge. */
13
+ falloff: 2,
14
+ /** Per-channel offset in px. Gives the prism fringe real glass has. */
15
+ chromaticAberration: 0,
16
+ /** Backdrop blur in px. Leave at 0 — blur is frosted glass, not liquid glass. */
17
+ blur: 15,
18
+ /** Backdrop saturation multiplier. */
19
+ saturation: 3,
20
+ /** Backdrop brightness multiplier. */
21
+ brightness: 1.04,
22
+ /** Specular rim intensity, 0 disables it. */
23
+ specular: 1,
24
+ /** Max pixel size of the generated displacement map. */
25
+ resolution: 512,
26
+ /** Blur used when the browser cannot run SVG filters in a backdrop-filter. */
27
+ fallbackBlur: 8,
28
+ }
29
+
30
+ let uid = 0
31
+
32
+ function supportsBackdropFilterUrl() {
33
+ if (typeof CSS === 'undefined' || !CSS.supports) return false
34
+ // Firefox parses backdrop-filter but silently drops url() references.
35
+ return (
36
+ CSS.supports('backdrop-filter', 'url(#x)') &&
37
+ !/\bGecko\/\b/.test(navigator.userAgent ?? '')
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Turns an element into a pane of liquid glass: everything behind it is
43
+ * refracted through a generated bevel instead of being blurred.
44
+ *
45
+ * @param {HTMLElement} element
46
+ * @param {Partial<typeof defaultOptions>} [options]
47
+ */
48
+ export function createLiquidGlass(element, options = {}) {
49
+ injectStyles()
50
+
51
+ let config = { ...defaultOptions, ...options }
52
+ let destroyed = false
53
+ let frame = 0
54
+
55
+ const useFilter = supportsBackdropFilterUrl()
56
+ const filter = useFilter ? createGlassFilter(`liquid-glass-${++uid}`) : null
57
+
58
+ element.classList.add('liquid-glass')
59
+
60
+ function applyBackdrop() {
61
+ const layers = []
62
+ if (config.brightness !== 1) layers.push(`brightness(${config.brightness})`)
63
+ if (config.saturation !== 1) layers.push(`saturate(${config.saturation})`)
64
+
65
+ if (filter) {
66
+ if (config.blur > 0) layers.push(`blur(${config.blur}px)`)
67
+ layers.push(filter.url)
68
+ } else if (config.fallbackBlur > 0) {
69
+ layers.push(`blur(${config.fallbackBlur}px)`)
70
+ }
71
+
72
+ element.style.setProperty('--lg-backdrop', layers.join(' ') || 'none')
73
+ element.style.setProperty('--lg-radius', `${config.radius}px`)
74
+ element.style.setProperty('--lg-specular', String(config.specular))
75
+ }
76
+
77
+ function render() {
78
+ frame = 0
79
+ if (destroyed || !filter) return
80
+
81
+ const { width, height } = element.getBoundingClientRect()
82
+ if (width < 1 || height < 1) return
83
+
84
+ const map = createDisplacementMap({
85
+ width,
86
+ height,
87
+ radius: config.radius,
88
+ depth: config.depth,
89
+ falloff: config.falloff,
90
+ resolution: config.resolution,
91
+ })
92
+
93
+ filter.update({
94
+ map: { ...map, displayWidth: width, displayHeight: height },
95
+ scale: config.strength,
96
+ aberration: config.chromaticAberration,
97
+ blur: 0,
98
+ })
99
+ }
100
+
101
+ function schedule() {
102
+ if (destroyed || frame) return
103
+ frame = requestAnimationFrame(render)
104
+ }
105
+
106
+ const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null
107
+ observer?.observe(element)
108
+
109
+ applyBackdrop()
110
+ schedule()
111
+
112
+ return {
113
+ get options() {
114
+ return { ...config }
115
+ },
116
+
117
+ update(next = {}) {
118
+ config = { ...config, ...next }
119
+ applyBackdrop()
120
+ schedule()
121
+ },
122
+
123
+ refresh: schedule,
124
+
125
+ destroy() {
126
+ if (destroyed) return
127
+ destroyed = true
128
+ if (frame) cancelAnimationFrame(frame)
129
+ observer?.disconnect()
130
+ filter?.destroy()
131
+ element.classList.remove('liquid-glass')
132
+ element.style.removeProperty('--lg-backdrop')
133
+ element.style.removeProperty('--lg-radius')
134
+ element.style.removeProperty('--lg-specular')
135
+ },
136
+ }
137
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createLiquidGlass, defaultOptions } from './glass.js'
2
+ export { createDisplacementMap } from './displacement.js'
3
+ export { injectStyles } from './styles.js'
package/src/styles.js ADDED
@@ -0,0 +1,35 @@
1
+ const STYLE_ID = 'mizuui-liquid-glass-core-styles'
2
+
3
+ const CSS = `
4
+ .liquid-glass {
5
+ position: relative;
6
+ isolation: isolate;
7
+ border-radius: var(--lg-radius, 24px);
8
+ -webkit-backdrop-filter: var(--lg-backdrop, none);
9
+ backdrop-filter: var(--lg-backdrop, none);
10
+ transform: translateZ(0);
11
+ }
12
+
13
+ .liquid-glass::after {
14
+ content: '';
15
+ position: absolute;
16
+ inset: 0;
17
+ z-index: 1;
18
+ border-radius: inherit;
19
+ pointer-events: none;
20
+ box-shadow:
21
+ inset 2px 2px 1px -2px rgba(255, 255, 255, calc(0.9 * var(--lg-specular, 1))),
22
+ inset -1.5px -1.5px 1px -1.5px rgba(255, 255, 255, calc(0.6 * var(--lg-specular, 1))),
23
+ inset 0 0 6px 1px rgba(255, 255, 255, calc(0.25 * var(--lg-specular, 1)));
24
+ }
25
+ `
26
+
27
+ export function injectStyles() {
28
+ if (typeof document === 'undefined') return
29
+ if (document.getElementById(STYLE_ID)) return
30
+
31
+ const style = document.createElement('style')
32
+ style.id = STYLE_ID
33
+ style.textContent = CSS
34
+ document.head.appendChild(style)
35
+ }