liqvued 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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/liqvued.css +2 -0
- package/dist/liqvued.mjs +240 -0
- package/dist/liqvued.umd.js +1 -0
- package/package.json +80 -0
- package/src/Liqvued.vue +471 -0
- package/src/index.ts +13 -0
- package/src/types.ts +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Igor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Liqvued (Liquid Vue)
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/liqvued)
|
|
4
|
+
[](https://www.npmjs.com/package/liqvued)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
A Vue 3 component for creating liquid glass effects using SVG filter primitives. Generates realistic SVG displacement maps on the fly.
|
|
8
|
+
|
|
9
|
+
Inspired by [Liquid Glass CSS/SVG](https://kube.io/blog/liquid-glass-css-svg/)
|
|
10
|
+
|
|
11
|
+
## Documentation
|
|
12
|
+
|
|
13
|
+
[View Documentation & Demo](https://ivoyt.github.io/liqvued)
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Real-time SVG filter generation** — displacement maps computed via Canvas API
|
|
18
|
+
- **Multiple surface types** — convex, concave, lip
|
|
19
|
+
- **Configurable optics** — bezel width, corner radius, displacement thickness, refraction index, glare angle
|
|
20
|
+
- **Built-in glassmorphism styling** — gradient backgrounds, inset shadows, highlight overlays
|
|
21
|
+
- **TypeScript** — full type definitions
|
|
22
|
+
- **Lightweight** — minimal runtime dependencies beyond Vue
|
|
23
|
+
- **Flexible composition** — `as` prop allows rendering as any HTML element or component
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm add liqvued
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```vue
|
|
34
|
+
<script setup lang="ts">
|
|
35
|
+
import { Liqvued } from 'liqvued'
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div style="background: url('bg.jpg') center/cover;">
|
|
40
|
+
<Liqvued
|
|
41
|
+
:radius="32"
|
|
42
|
+
:bezel="22"
|
|
43
|
+
:thickness="42"
|
|
44
|
+
:refraction="1"
|
|
45
|
+
:blur="0.4"
|
|
46
|
+
surface="convex"
|
|
47
|
+
>
|
|
48
|
+
<p>Content behind the glass</p>
|
|
49
|
+
</Liqvued>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Props
|
|
55
|
+
|
|
56
|
+
| Prop | Type | Default | Description |
|
|
57
|
+
|-----------------|---------------------|------------|---------------------------------------------------------------------------------|
|
|
58
|
+
| radius | number | 32 | Corner radius in pixels |
|
|
59
|
+
| bezel | number | 22 | Width of the displacement bezel in pixels |
|
|
60
|
+
| thickness | number | 42 | Maximum displacement magnitude in pixels |
|
|
61
|
+
| refraction | number | 1 | Refraction index multiplier |
|
|
62
|
+
| blur | number | 0.4 | CSS backdrop-filter blur amount |
|
|
63
|
+
| surface | string | `'convex'` | Glass surface profile shape |
|
|
64
|
+
| specularOpacity | number | 0.45 | Opacity of the specular highlight |
|
|
65
|
+
| glareAngle | number | -60 | Light source angle in degrees for specular highlight |
|
|
66
|
+
| glassBackground | string | — | Glass panel background color (auto-derived from `asProps.color` when available) |
|
|
67
|
+
| as | string \| Component | `'div'` | HTML tag or Vue component to render as |
|
|
68
|
+
| asProps | object | `{}` | Props passed to the rendered element/component |
|
|
69
|
+
|
|
70
|
+
### Surface Types
|
|
71
|
+
|
|
72
|
+
- `convex` — squircle convex profile — soft pill-like edges
|
|
73
|
+
- `concave` — inverted convex — inward-curving dish-like refraction
|
|
74
|
+
- `lip` — smooth transition from convex at center to concave at edge
|
|
75
|
+
|
|
76
|
+
## Dynamic Element Rendering
|
|
77
|
+
|
|
78
|
+
Use the `as` prop to render the glass effect on any element or component:
|
|
79
|
+
|
|
80
|
+
```vue
|
|
81
|
+
<template>
|
|
82
|
+
<Liqvued as="section" :as-props="{ class: 'my-panel' }">
|
|
83
|
+
<p>Section with glass effect</p>
|
|
84
|
+
</Liqvued>
|
|
85
|
+
</template>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Browser Compatibility
|
|
89
|
+
|
|
90
|
+
| Browser | Effect |
|
|
91
|
+
|-------------------------|-----------------------------------------------------------------------------------|
|
|
92
|
+
| Chrome / Edge / Opera | ✅ Full liquid glass (SVG backdrop-filter) |
|
|
93
|
+
| Safari / Firefox | ❌ Glassmorphism blur fallback (auto-detected via `CSS.supports()` + UA check) |
|
|
94
|
+
|
|
95
|
+
The fallback applies `backdrop-filter: blur(12px)` with a subtle border and shadow, so the component still looks like frosted glass in unsupported browsers. SVG filter generation is skipped entirely in fallback mode — no unnecessary CPU/GPU work.
|
|
96
|
+
|
|
97
|
+
## How It Works
|
|
98
|
+
|
|
99
|
+
The component generates a per-pixel displacement map as an RGBA PNG (red/green channels encode X/Y displacement vectors), injects it into an SVG `<feDisplacementMap>` filter, and applies it via `backdrop-filter` combined with CSS blur. The surface profile functions (`convex`, `concave`, `lip`) define the slope at each point along the bezel, which determines the displacement magnitude. A separate specular highlight map is generated and composited via `feBlend` to simulate surface lighting.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT — see [LICENSE](LICENSE)
|
package/dist/liqvued.css
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
.liquid-glass[data-v-2c1a4b58]{isolation:isolate;position:relative;overflow:hidden}.liquid-glass__svg[data-v-2c1a4b58]{pointer-events:none;width:100%;height:100%;position:absolute;inset:0}@supports not ((-webkit-backdrop-filter:url("#x")) or (backdrop-filter:url("#x"))){.liquid-glass[data-v-2c1a4b58]{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}}
|
|
2
|
+
/*$vite$:1*/
|
package/dist/liqvued.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { computed as R, createBlock as le, createCommentVNode as ue, createElementBlock as ce, createElementVNode as w, defineComponent as ie, inject as de, mergeProps as fe, nextTick as K, onBeforeUnmount as he, onMounted as pe, openBlock as Q, provide as ve, ref as M, renderSlot as ge, resolveDynamicComponent as me, unref as be, watch as Me, withCtx as _e } from "vue";
|
|
2
|
+
var ye = {
|
|
3
|
+
key: 0,
|
|
4
|
+
class: "liquid-glass__svg",
|
|
5
|
+
"aria-hidden": "true"
|
|
6
|
+
}, xe = [
|
|
7
|
+
"href",
|
|
8
|
+
"width",
|
|
9
|
+
"height"
|
|
10
|
+
], ke = ["scale"], we = [
|
|
11
|
+
"href",
|
|
12
|
+
"width",
|
|
13
|
+
"height"
|
|
14
|
+
], Ie = /* @__PURE__ */ ie({
|
|
15
|
+
__name: "Liqvued",
|
|
16
|
+
props: {
|
|
17
|
+
as: { default: "div" },
|
|
18
|
+
asProps: { default: () => ({}) },
|
|
19
|
+
radius: { default: 32 },
|
|
20
|
+
bezel: { default: 22 },
|
|
21
|
+
thickness: { default: 42 },
|
|
22
|
+
refraction: { default: 1 },
|
|
23
|
+
blur: { default: 0.4 },
|
|
24
|
+
surface: { default: "convex" },
|
|
25
|
+
specularOpacity: { default: 0.45 },
|
|
26
|
+
glareAngle: { default: -60 },
|
|
27
|
+
glassBackground: { default: void 0 }
|
|
28
|
+
},
|
|
29
|
+
setup(p) {
|
|
30
|
+
const a = p, v = M(null), _ = M(1), y = M(1), F = M(""), U = M(""), z = M(1), x = CSS.supports?.("backdrop-filter", "url(#x)") && /Chrome/.test(navigator.userAgent), O = `${`lg-${Math.random().toString(36).slice(2)}`}-filter`, T = de("_liqvued", !1);
|
|
31
|
+
ve("_liqvued", !0);
|
|
32
|
+
const k = M(!1), A = {
|
|
33
|
+
0: 0,
|
|
34
|
+
sm: 4,
|
|
35
|
+
md: 8,
|
|
36
|
+
lg: 12,
|
|
37
|
+
xl: 16,
|
|
38
|
+
pill: 9999,
|
|
39
|
+
circle: 9999,
|
|
40
|
+
shaped: 16
|
|
41
|
+
};
|
|
42
|
+
function W() {
|
|
43
|
+
return v.value ? v.value instanceof Element ? v.value : v.value.$el ?? null : null;
|
|
44
|
+
}
|
|
45
|
+
function f(e, t = 0, n = 1) {
|
|
46
|
+
return Math.max(t, Math.min(n, e));
|
|
47
|
+
}
|
|
48
|
+
function X(e) {
|
|
49
|
+
return e = f(e), e * e * e * (e * (e * 6 - 15) + 10);
|
|
50
|
+
}
|
|
51
|
+
function I(e) {
|
|
52
|
+
return Math.pow(1 - Math.pow(1 - f(e), 4), 1 / 4);
|
|
53
|
+
}
|
|
54
|
+
function G(e) {
|
|
55
|
+
if (a.surface === "concave") return 1 - I(e);
|
|
56
|
+
if (a.surface === "lip") {
|
|
57
|
+
const t = X(e);
|
|
58
|
+
return I(e) * (1 - t) + (1 - I(e)) * t;
|
|
59
|
+
}
|
|
60
|
+
return I(e);
|
|
61
|
+
}
|
|
62
|
+
function Y(e) {
|
|
63
|
+
return (G(e + 1e-3) - G(e - 1e-3)) / (2 * 1e-3);
|
|
64
|
+
}
|
|
65
|
+
function Z(e) {
|
|
66
|
+
const s = Math.atan(e), r = 1 / 1.5 * Math.sin(s), g = Math.asin(f(r, -1, 1));
|
|
67
|
+
return Math.tan(s - g);
|
|
68
|
+
}
|
|
69
|
+
function ee(e, t, n, s, r) {
|
|
70
|
+
const g = f(e, r, n - r), h = f(t, r, s - r), o = e - g, l = t - h, c = Math.hypot(o, l), m = Math.min(e, t, n - e, s - t);
|
|
71
|
+
return o !== 0 && l !== 0 ? r - c : m;
|
|
72
|
+
}
|
|
73
|
+
function te(e, t, n, s, r) {
|
|
74
|
+
const g = f(e, r, n - r), h = f(t, r, s - r);
|
|
75
|
+
let o = e - g, l = t - h;
|
|
76
|
+
if (o === 0 && l === 0) {
|
|
77
|
+
const m = e, i = n - e, d = t, b = s - t, u = Math.min(m, i, d, b);
|
|
78
|
+
u === m ? o = -1 : u === i ? o = 1 : u === d ? l = -1 : l = 1;
|
|
79
|
+
}
|
|
80
|
+
const c = Math.hypot(o, l) || 1;
|
|
81
|
+
return {
|
|
82
|
+
x: o / c,
|
|
83
|
+
y: l / c
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function N(e) {
|
|
87
|
+
return e.toDataURL("image/png");
|
|
88
|
+
}
|
|
89
|
+
function q() {
|
|
90
|
+
const e = Math.max(1, Math.round(_.value)), t = Math.max(1, Math.round(y.value)), n = document.createElement("canvas");
|
|
91
|
+
n.width = e, n.height = t;
|
|
92
|
+
const s = document.createElement("canvas");
|
|
93
|
+
s.width = e, s.height = t;
|
|
94
|
+
const r = n.getContext("2d"), g = s.getContext("2d"), h = r.createImageData(e, t), o = g.createImageData(e, t), l = [];
|
|
95
|
+
let c = 1;
|
|
96
|
+
const m = {
|
|
97
|
+
x: Math.cos(a.glareAngle * Math.PI / 180),
|
|
98
|
+
y: Math.sin(a.glareAngle * Math.PI / 180)
|
|
99
|
+
};
|
|
100
|
+
for (let i = 0; i < t; i++) for (let d = 0; d < e; d++) {
|
|
101
|
+
const b = ee(d, i, e, t, S.value), u = f(b / a.bezel);
|
|
102
|
+
let L = 0, P = 0, H = 0;
|
|
103
|
+
if (b >= 0 && b < a.bezel) {
|
|
104
|
+
const C = te(d, i, e, t, S.value), J = Z(Y(u)) * a.thickness * (1 - u) * a.refraction;
|
|
105
|
+
L = -C.x * J, P = -C.y * J, c = Math.max(c, Math.abs(L), Math.abs(P));
|
|
106
|
+
const se = f(C.x * m.x + C.y * m.y, 0, 1);
|
|
107
|
+
H = Math.pow(se, 18) * a.specularOpacity;
|
|
108
|
+
}
|
|
109
|
+
l.push([L, P]);
|
|
110
|
+
const B = (i * e + d) * 4, oe = Math.round(H * 255);
|
|
111
|
+
o.data[B] = 255, o.data[B + 1] = 255, o.data[B + 2] = 255, o.data[B + 3] = oe;
|
|
112
|
+
}
|
|
113
|
+
l.forEach(([i, d], b) => {
|
|
114
|
+
const u = b * 4;
|
|
115
|
+
h.data[u] = Math.round(128 + i / c * 127), h.data[u + 1] = Math.round(128 + d / c * 127), h.data[u + 2] = 128, h.data[u + 3] = 255;
|
|
116
|
+
}), r.putImageData(h, 0, 0), g.putImageData(o, 0, 0), F.value = N(n), U.value = N(s), z.value = c;
|
|
117
|
+
}
|
|
118
|
+
function V() {
|
|
119
|
+
const e = W();
|
|
120
|
+
e && (_.value = Math.max(1, e.offsetWidth), y.value = Math.max(1, e.offsetHeight));
|
|
121
|
+
}
|
|
122
|
+
const j = x ? "rgba(255, 255, 255, 0.08)" : "rgba(255, 255, 255, 0.22)", S = R(() => {
|
|
123
|
+
if (a.radius !== 32) return a.radius;
|
|
124
|
+
const e = a.asProps;
|
|
125
|
+
return typeof e.rounded == "string" && e.rounded in A ? A[e.rounded] : a.radius;
|
|
126
|
+
}), ae = /* @__PURE__ */ new Set([
|
|
127
|
+
"primary",
|
|
128
|
+
"secondary",
|
|
129
|
+
"accent",
|
|
130
|
+
"info",
|
|
131
|
+
"warning",
|
|
132
|
+
"error",
|
|
133
|
+
"success",
|
|
134
|
+
"surface",
|
|
135
|
+
"background"
|
|
136
|
+
]);
|
|
137
|
+
function ne(e, t) {
|
|
138
|
+
if (ae.has(e)) return `rgba(var(--v-theme-${e}), ${t})`;
|
|
139
|
+
if (e.startsWith("#")) {
|
|
140
|
+
const n = e.replace("#", "");
|
|
141
|
+
return `rgba(${parseInt(n.slice(0, 2), 16)}, ${parseInt(n.slice(2, 4), 16)}, ${parseInt(n.slice(4, 6), 16)}, ${t})`;
|
|
142
|
+
}
|
|
143
|
+
return e.startsWith("rgb") ? e.replace("rgb(", "rgba(").replace(")", `, ${t})`) : j;
|
|
144
|
+
}
|
|
145
|
+
const D = R(() => {
|
|
146
|
+
if (a.glassBackground !== void 0) return a.glassBackground;
|
|
147
|
+
const e = a.asProps;
|
|
148
|
+
return typeof e.color == "string" ? ne(e.color, 0.6) : j;
|
|
149
|
+
}), $ = R(() => x ? `url(#${O}) blur(${a.blur}px)` : "blur(12px)"), re = R(() => ({
|
|
150
|
+
borderRadius: `${S.value}px`,
|
|
151
|
+
...D.value !== void 0 ? { backgroundColor: D.value } : {},
|
|
152
|
+
...D.value !== void 0 ? { boxShadow: ["inset 0 0 0 1px rgba(255, 255, 255, 0.28)", "0 18px 48px rgba(0, 0, 0, 0.18)"].join(", ") } : {},
|
|
153
|
+
...k.value && !T ? {
|
|
154
|
+
backdropFilter: $.value,
|
|
155
|
+
WebkitBackdropFilter: $.value
|
|
156
|
+
} : {},
|
|
157
|
+
...k.value && T && !x ? {
|
|
158
|
+
backdropFilter: $.value,
|
|
159
|
+
WebkitBackdropFilter: $.value
|
|
160
|
+
} : {}
|
|
161
|
+
}));
|
|
162
|
+
let E = null;
|
|
163
|
+
return pe(async () => {
|
|
164
|
+
if (await K(), x) {
|
|
165
|
+
V(), q(), await K(), k.value = !0, E = new ResizeObserver(() => {
|
|
166
|
+
V(), q(), k.value = !0;
|
|
167
|
+
});
|
|
168
|
+
const e = W();
|
|
169
|
+
e && E.observe(e);
|
|
170
|
+
} else k.value = !0;
|
|
171
|
+
}), Me(() => [
|
|
172
|
+
S.value,
|
|
173
|
+
a.bezel,
|
|
174
|
+
a.thickness,
|
|
175
|
+
a.refraction,
|
|
176
|
+
a.surface,
|
|
177
|
+
a.specularOpacity,
|
|
178
|
+
a.glareAngle
|
|
179
|
+
], () => {
|
|
180
|
+
x && q(), k.value = !0;
|
|
181
|
+
}), he(() => {
|
|
182
|
+
E?.disconnect();
|
|
183
|
+
}), (e, t) => (Q(), le(me(p.as), fe({
|
|
184
|
+
ref_key: "root",
|
|
185
|
+
ref: v,
|
|
186
|
+
class: "liquid-glass"
|
|
187
|
+
}, p.asProps, { style: re.value }), {
|
|
188
|
+
default: _e(() => [be(x) ? (Q(), ce("svg", ye, [w("defs", null, [w("filter", {
|
|
189
|
+
id: O,
|
|
190
|
+
"color-interpolation-filters": "sRGB",
|
|
191
|
+
filterUnits: "userSpaceOnUse",
|
|
192
|
+
x: "0",
|
|
193
|
+
y: "0",
|
|
194
|
+
width: "10000",
|
|
195
|
+
height: "10000"
|
|
196
|
+
}, [
|
|
197
|
+
w("feImage", {
|
|
198
|
+
href: F.value,
|
|
199
|
+
x: "0",
|
|
200
|
+
y: "0",
|
|
201
|
+
width: _.value,
|
|
202
|
+
height: y.value,
|
|
203
|
+
result: "displacement_map"
|
|
204
|
+
}, null, 8, xe),
|
|
205
|
+
w("feDisplacementMap", {
|
|
206
|
+
in: "SourceGraphic",
|
|
207
|
+
in2: "displacement_map",
|
|
208
|
+
scale: z.value,
|
|
209
|
+
xChannelSelector: "R",
|
|
210
|
+
yChannelSelector: "G",
|
|
211
|
+
result: "refracted"
|
|
212
|
+
}, null, 8, ke),
|
|
213
|
+
w("feImage", {
|
|
214
|
+
href: U.value,
|
|
215
|
+
x: "0",
|
|
216
|
+
y: "0",
|
|
217
|
+
width: _.value,
|
|
218
|
+
height: y.value,
|
|
219
|
+
result: "specular"
|
|
220
|
+
}, null, 8, we),
|
|
221
|
+
t[0] || (t[0] = w("feBlend", {
|
|
222
|
+
in: "refracted",
|
|
223
|
+
in2: "specular",
|
|
224
|
+
mode: "screen"
|
|
225
|
+
}, null, -1))
|
|
226
|
+
])])])) : ue("", !0), ge(e.$slots, "default", {}, void 0, !0)]),
|
|
227
|
+
_: 3
|
|
228
|
+
}, 16, ["style"]));
|
|
229
|
+
}
|
|
230
|
+
}), Se = (p, a) => {
|
|
231
|
+
const v = p.__vccOpts || p;
|
|
232
|
+
for (const [_, y] of a) v[_] = y;
|
|
233
|
+
return v;
|
|
234
|
+
}, $e = /* @__PURE__ */ Se(Ie, [["__scopeId", "data-v-2c1a4b58"]]), Ce = { install(p) {
|
|
235
|
+
p.component("Liqvued", $e);
|
|
236
|
+
} };
|
|
237
|
+
export {
|
|
238
|
+
$e as Liqvued,
|
|
239
|
+
Ce as default
|
|
240
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(g,t){typeof exports=="object"&&typeof module<"u"?t(exports,require("vue")):typeof define=="function"&&define.amd?define(["exports","vue"],t):(g=typeof globalThis<"u"?globalThis:g||self,t(g.Liqvued={},g.Vue))})(this,function(g,t){Object.defineProperties(g,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});var K={key:0,class:"liquid-glass__svg","aria-hidden":"true"},Q=["href","width","height"],X=["scale"],Y=["href","width","height"],Z=(0,t.defineComponent)({__name:"Liqvued",props:{as:{default:"div"},asProps:{default:()=>({})},radius:{default:32},bezel:{default:22},thickness:{default:42},refraction:{default:1},blur:{default:.4},surface:{default:"convex"},specularOpacity:{default:.45},glareAngle:{default:-60},glassBackground:{default:void 0}},setup(m){const n=m,v=(0,t.ref)(null),y=(0,t.ref)(1),k=(0,t.ref)(1),P=(0,t.ref)(""),V=(0,t.ref)(""),N=(0,t.ref)(1),x=CSS.supports?.("backdrop-filter","url(#x)")&&/Chrome/.test(navigator.userAgent),F=`${`lg-${Math.random().toString(36).slice(2)}`}-filter`,O=(0,t.inject)("_liqvued",!1);(0,t.provide)("_liqvued",!0);const w=(0,t.ref)(!1),U={0:0,sm:4,md:8,lg:12,xl:16,pill:9999,circle:9999,shaped:16};function z(){return v.value?v.value instanceof Element?v.value:v.value.$el??null:null}function h(e,a=0,r=1){return Math.max(a,Math.min(r,e))}function ae(e){return e=h(e),e*e*e*(e*(e*6-15)+10)}function S(e){return Math.pow(1-Math.pow(1-h(e),4),1/4)}function A(e){if(n.surface==="concave")return 1-S(e);if(n.surface==="lip"){const a=ae(e);return S(e)*(1-a)+(1-S(e))*a}return S(e)}function ne(e){return(A(e+.001)-A(e-.001))/(2*.001)}function re(e){const s=Math.atan(e),o=1/1.5*Math.sin(s),b=Math.asin(h(o,-1,1));return Math.tan(s-b)}function oe(e,a,r,s,o){const b=h(e,o,r-o),p=h(a,o,s-o),l=e-b,c=a-p,u=Math.hypot(l,c),M=Math.min(e,a,r-e,s-a);return l!==0&&c!==0?o-u:M}function le(e,a,r,s,o){const b=h(e,o,r-o),p=h(a,o,s-o);let l=e-b,c=a-p;if(l===0&&c===0){const M=e,d=r-e,f=a,_=s-a,i=Math.min(M,d,f,_);i===M?l=-1:i===d?l=1:i===f?c=-1:c=1}const u=Math.hypot(l,c)||1;return{x:l/u,y:c/u}}function W(e){return e.toDataURL("image/png")}function E(){const e=Math.max(1,Math.round(y.value)),a=Math.max(1,Math.round(k.value)),r=document.createElement("canvas");r.width=e,r.height=a;const s=document.createElement("canvas");s.width=e,s.height=a;const o=r.getContext("2d"),b=s.getContext("2d"),p=o.createImageData(e,a),l=b.createImageData(e,a),c=[];let u=1;const M={x:Math.cos(n.glareAngle*Math.PI/180),y:Math.sin(n.glareAngle*Math.PI/180)};for(let d=0;d<a;d++)for(let f=0;f<e;f++){const _=oe(f,d,e,a,I.value),i=h(_/n.bezel);let T=0,D=0,H=0;if(_>=0&&_<n.bezel){const q=le(f,d,e,a,I.value),J=re(ne(i))*n.thickness*(1-i)*n.refraction;T=-q.x*J,D=-q.y*J,u=Math.max(u,Math.abs(T),Math.abs(D));const de=h(q.x*M.x+q.y*M.y,0,1);H=Math.pow(de,18)*n.specularOpacity}c.push([T,D]);const $=(d*e+f)*4,ue=Math.round(H*255);l.data[$]=255,l.data[$+1]=255,l.data[$+2]=255,l.data[$+3]=ue}c.forEach(([d,f],_)=>{const i=_*4;p.data[i]=Math.round(128+d/u*127),p.data[i+1]=Math.round(128+f/u*127),p.data[i+2]=128,p.data[i+3]=255}),o.putImageData(p,0,0),b.putImageData(l,0,0),P.value=W(r),V.value=W(s),N.value=u}function j(){const e=z();e&&(y.value=Math.max(1,e.offsetWidth),k.value=Math.max(1,e.offsetHeight))}const G=x?"rgba(255, 255, 255, 0.08)":"rgba(255, 255, 255, 0.22)",I=(0,t.computed)(()=>{if(n.radius!==32)return n.radius;const e=n.asProps;return typeof e.rounded=="string"&&e.rounded in U?U[e.rounded]:n.radius}),se=new Set(["primary","secondary","accent","info","warning","error","success","surface","background"]);function ce(e,a){if(se.has(e))return`rgba(var(--v-theme-${e}), ${a})`;if(e.startsWith("#")){const r=e.replace("#","");return`rgba(${parseInt(r.slice(0,2),16)}, ${parseInt(r.slice(2,4),16)}, ${parseInt(r.slice(4,6),16)}, ${a})`}return e.startsWith("rgb")?e.replace("rgb(","rgba(").replace(")",`, ${a})`):G}const C=(0,t.computed)(()=>{if(n.glassBackground!==void 0)return n.glassBackground;const e=n.asProps;return typeof e.color=="string"?ce(e.color,.6):G}),B=(0,t.computed)(()=>x?`url(#${F}) blur(${n.blur}px)`:"blur(12px)"),ie=(0,t.computed)(()=>({borderRadius:`${I.value}px`,...C.value!==void 0?{backgroundColor:C.value}:{},...C.value!==void 0?{boxShadow:["inset 0 0 0 1px rgba(255, 255, 255, 0.28)","0 18px 48px rgba(0, 0, 0, 0.18)"].join(", ")}:{},...w.value&&!O?{backdropFilter:B.value,WebkitBackdropFilter:B.value}:{},...w.value&&O&&!x?{backdropFilter:B.value,WebkitBackdropFilter:B.value}:{}}));let R=null;return(0,t.onMounted)(async()=>{if(await(0,t.nextTick)(),x){j(),E(),await(0,t.nextTick)(),w.value=!0,R=new ResizeObserver(()=>{j(),E(),w.value=!0});const e=z();e&&R.observe(e)}else w.value=!0}),(0,t.watch)(()=>[I.value,n.bezel,n.thickness,n.refraction,n.surface,n.specularOpacity,n.glareAngle],()=>{x&&E(),w.value=!0}),(0,t.onBeforeUnmount)(()=>{R?.disconnect()}),(e,a)=>((0,t.openBlock)(),(0,t.createBlock)((0,t.resolveDynamicComponent)(m.as),(0,t.mergeProps)({ref_key:"root",ref:v,class:"liquid-glass"},m.asProps,{style:ie.value}),{default:(0,t.withCtx)(()=>[(0,t.unref)(x)?((0,t.openBlock)(),(0,t.createElementBlock)("svg",K,[(0,t.createElementVNode)("defs",null,[(0,t.createElementVNode)("filter",{id:F,"color-interpolation-filters":"sRGB",filterUnits:"userSpaceOnUse",x:"0",y:"0",width:"10000",height:"10000"},[(0,t.createElementVNode)("feImage",{href:P.value,x:"0",y:"0",width:y.value,height:k.value,result:"displacement_map"},null,8,Q),(0,t.createElementVNode)("feDisplacementMap",{in:"SourceGraphic",in2:"displacement_map",scale:N.value,xChannelSelector:"R",yChannelSelector:"G",result:"refracted"},null,8,X),(0,t.createElementVNode)("feImage",{href:V.value,x:"0",y:"0",width:y.value,height:k.value,result:"specular"},null,8,Y),a[0]||(a[0]=(0,t.createElementVNode)("feBlend",{in:"refracted",in2:"specular",mode:"screen"},null,-1))])])])):(0,t.createCommentVNode)("",!0),(0,t.renderSlot)(e.$slots,"default",{},void 0,!0)]),_:3},16,["style"]))}}),ee=(m,n)=>{const v=m.__vccOpts||m;for(const[y,k]of n)v[y]=k;return v},L=ee(Z,[["__scopeId","data-v-2c1a4b58"]]),te={install(m){m.component("Liqvued",L)}};g.Liqvued=L,g.default=te});
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "liqvued",
|
|
3
|
+
"types": "./index.d.ts",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Liquid-glass for Vue",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": {
|
|
9
|
+
"development": "./src/index.ts",
|
|
10
|
+
"default": "./dist/liqvued.mjs"
|
|
11
|
+
},
|
|
12
|
+
"require": {
|
|
13
|
+
"development": "./src/index.ts",
|
|
14
|
+
"default": "./dist/liqvued.umd.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"./src/*": "./src/*",
|
|
18
|
+
"./dist/*": "./dist/*",
|
|
19
|
+
"./style.css": "./dist/liqvued.css"
|
|
20
|
+
},
|
|
21
|
+
"main": "./dist/liqvued.umd.cjs",
|
|
22
|
+
"module": "./dist/liqvued.mjs",
|
|
23
|
+
"source": "./src/index.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src",
|
|
27
|
+
"index.d.ts"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "vite",
|
|
31
|
+
"build": "vite build",
|
|
32
|
+
"lint": "eslint src --fix --ext .ts,.js,.cjs,.vue,.tsx,.jsx",
|
|
33
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
34
|
+
"docs:dev": "pnpm --filter liqvued-docs docs:dev",
|
|
35
|
+
"docs:build": "pnpm --filter liqvued-docs docs:build",
|
|
36
|
+
"docs:preview": "pnpm --filter liqvued-docs docs:preview"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"vue",
|
|
40
|
+
"vue3",
|
|
41
|
+
"vue.js",
|
|
42
|
+
"typescript",
|
|
43
|
+
"liquid",
|
|
44
|
+
"glass",
|
|
45
|
+
"liquid-glass",
|
|
46
|
+
"liquid glass",
|
|
47
|
+
"liqvued"
|
|
48
|
+
],
|
|
49
|
+
"author": "Igor Voytovich",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"repository": "https://github.com/IVoyt/liqvued",
|
|
52
|
+
"packageManager": "pnpm@10.33.4",
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"vue": "^3.5.14"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@intlify/unplugin-vue-i18n": "^11.1.1",
|
|
58
|
+
"@rollup/plugin-alias": "^5.1.1",
|
|
59
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
60
|
+
"@types/node": "^22.19.15",
|
|
61
|
+
"@vitejs/plugin-vue": "^6.0.5",
|
|
62
|
+
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
|
63
|
+
"eslint": "^10.3.0",
|
|
64
|
+
"eslint-import-resolver-typescript": "^4.4.4",
|
|
65
|
+
"eslint-plugin-case-police": "^2.2.1",
|
|
66
|
+
"eslint-plugin-import": "^2.32.0",
|
|
67
|
+
"eslint-plugin-promise": "^7.2.1",
|
|
68
|
+
"eslint-plugin-sonarjs": "^4.0.3",
|
|
69
|
+
"eslint-plugin-unicorn": "^64.0.0",
|
|
70
|
+
"eslint-plugin-vue": "^10.9.0",
|
|
71
|
+
"sass-embedded": "^1.98.0",
|
|
72
|
+
"tsx": "^4.21.0",
|
|
73
|
+
"typescript": "^5.9.3",
|
|
74
|
+
"typescript-eslint": "^8.59.0",
|
|
75
|
+
"unplugin-vue-components": "^28.8.0",
|
|
76
|
+
"vite": "^8.0.10",
|
|
77
|
+
"vue-eslint-parser": "^10.4.0",
|
|
78
|
+
"vue-tsc": "^3.2.6"
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/Liqvued.vue
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type ComponentPublicInstance,
|
|
5
|
+
computed,
|
|
6
|
+
inject,
|
|
7
|
+
nextTick,
|
|
8
|
+
onBeforeUnmount,
|
|
9
|
+
onMounted,
|
|
10
|
+
provide,
|
|
11
|
+
ref,
|
|
12
|
+
watch,
|
|
13
|
+
} from 'vue'
|
|
14
|
+
import type { Surface } from './types'
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(defineProps<{
|
|
17
|
+
as?: string | Component
|
|
18
|
+
asProps?: object
|
|
19
|
+
radius?: number
|
|
20
|
+
bezel?: number
|
|
21
|
+
thickness?: number
|
|
22
|
+
refraction?: number
|
|
23
|
+
blur?: number
|
|
24
|
+
surface?: Surface
|
|
25
|
+
specularOpacity?: number
|
|
26
|
+
glareAngle?: number
|
|
27
|
+
glassBackground?: string
|
|
28
|
+
}>(), {
|
|
29
|
+
as: 'div',
|
|
30
|
+
asProps: () => ({}),
|
|
31
|
+
radius: 32,
|
|
32
|
+
bezel: 22,
|
|
33
|
+
thickness: 42,
|
|
34
|
+
refraction: 1,
|
|
35
|
+
blur: 0.4,
|
|
36
|
+
surface: 'convex',
|
|
37
|
+
specularOpacity: 0.45,
|
|
38
|
+
glareAngle: -60,
|
|
39
|
+
glassBackground: undefined,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const root = ref<HTMLElement | ComponentPublicInstance | null>(null)
|
|
43
|
+
|
|
44
|
+
const width = ref(1)
|
|
45
|
+
const height = ref(1)
|
|
46
|
+
const mapUrl = ref('')
|
|
47
|
+
const specularUrl = ref('')
|
|
48
|
+
const scale = ref(1)
|
|
49
|
+
const supportsLiquidGlass =
|
|
50
|
+
CSS.supports?.('backdrop-filter', 'url(#x)') &&
|
|
51
|
+
/Chrome/.test(navigator.userAgent)
|
|
52
|
+
|
|
53
|
+
const id = `lg-${Math.random().toString(36).slice(2)}`
|
|
54
|
+
const filterId = `${id}-filter`
|
|
55
|
+
const isNested = inject('_liqvued', false)
|
|
56
|
+
provide('_liqvued', true)
|
|
57
|
+
const glassReady = ref(false)
|
|
58
|
+
|
|
59
|
+
const roundedMap: Record<string, number> = {
|
|
60
|
+
'0': 0,
|
|
61
|
+
sm: 4,
|
|
62
|
+
md: 8,
|
|
63
|
+
lg: 12,
|
|
64
|
+
xl: 16,
|
|
65
|
+
pill: 9999,
|
|
66
|
+
circle: 9999,
|
|
67
|
+
shaped: 16,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getRootEl(): HTMLElement | null {
|
|
71
|
+
if (!root.value) return null
|
|
72
|
+
if (root.value instanceof Element) return root.value as HTMLElement
|
|
73
|
+
return (root.value as ComponentPublicInstance).$el ?? null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clamp(v: number, min = 0, max = 1) {
|
|
77
|
+
return Math.max(min, Math.min(max, v))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function smootherStep(x: number) {
|
|
81
|
+
x = clamp(x)
|
|
82
|
+
return x * x * x * (x * (x * 6 - 15) + 10)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function convex(x: number) {
|
|
86
|
+
return Math.pow(1 - Math.pow(1 - clamp(x), 4), 1 / 4)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function surfaceFn(x: number) {
|
|
90
|
+
if (props.surface === 'concave') return 1 - convex(x)
|
|
91
|
+
if (props.surface === 'lip') {
|
|
92
|
+
const t = smootherStep(x)
|
|
93
|
+
return convex(x) * (1 - t) + (1 - convex(x)) * t
|
|
94
|
+
}
|
|
95
|
+
return convex(x)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function derivative(x: number) {
|
|
99
|
+
const d = 0.001
|
|
100
|
+
return (surfaceFn(x + d) - surfaceFn(x - d)) / (2 * d)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function refractSlope(slope: number) {
|
|
104
|
+
const n1 = 1
|
|
105
|
+
const n2 = 1.5
|
|
106
|
+
|
|
107
|
+
const normalAngle = Math.atan(slope)
|
|
108
|
+
const theta1 = normalAngle
|
|
109
|
+
|
|
110
|
+
const sinTheta2 = (n1 / n2) * Math.sin(theta1)
|
|
111
|
+
const theta2 = Math.asin(clamp(sinTheta2, -1, 1))
|
|
112
|
+
|
|
113
|
+
return Math.tan(theta1 - theta2)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function distanceToRoundedRectEdge(
|
|
117
|
+
x: number,
|
|
118
|
+
y: number,
|
|
119
|
+
w: number,
|
|
120
|
+
h: number,
|
|
121
|
+
r: number,
|
|
122
|
+
) {
|
|
123
|
+
const cx = clamp(x, r, w - r)
|
|
124
|
+
const cy = clamp(y, r, h - r)
|
|
125
|
+
|
|
126
|
+
const dx = x - cx
|
|
127
|
+
const dy = y - cy
|
|
128
|
+
|
|
129
|
+
const cornerDistance = Math.hypot(dx, dy)
|
|
130
|
+
const edgeDistance = Math.min(x, y, w - x, h - y)
|
|
131
|
+
|
|
132
|
+
if (dx !== 0 && dy !== 0) {
|
|
133
|
+
return r - cornerDistance
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return edgeDistance
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalFromRoundedRect(
|
|
140
|
+
x: number,
|
|
141
|
+
y: number,
|
|
142
|
+
w: number,
|
|
143
|
+
h: number,
|
|
144
|
+
r: number,
|
|
145
|
+
) {
|
|
146
|
+
const cx = clamp(x, r, w - r)
|
|
147
|
+
const cy = clamp(y, r, h - r)
|
|
148
|
+
|
|
149
|
+
let nx = x - cx
|
|
150
|
+
let ny = y - cy
|
|
151
|
+
|
|
152
|
+
if (nx === 0 && ny === 0) {
|
|
153
|
+
const left = x
|
|
154
|
+
const right = w - x
|
|
155
|
+
const top = y
|
|
156
|
+
const bottom = h - y
|
|
157
|
+
const m = Math.min(left, right, top, bottom)
|
|
158
|
+
|
|
159
|
+
if (m === left) nx = -1
|
|
160
|
+
else if (m === right) nx = 1
|
|
161
|
+
else if (m === top) ny = -1
|
|
162
|
+
else ny = 1
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const len = Math.hypot(nx, ny) || 1
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
x: nx / len,
|
|
169
|
+
y: ny / len,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function canvasToUrl(canvas: HTMLCanvasElement) {
|
|
174
|
+
return canvas.toDataURL('image/png')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildMaps() {
|
|
178
|
+
const w = Math.max(1, Math.round(width.value))
|
|
179
|
+
const h = Math.max(1, Math.round(height.value))
|
|
180
|
+
|
|
181
|
+
const displacement = document.createElement('canvas')
|
|
182
|
+
displacement.width = w
|
|
183
|
+
displacement.height = h
|
|
184
|
+
|
|
185
|
+
const highlight = document.createElement('canvas')
|
|
186
|
+
highlight.width = w
|
|
187
|
+
highlight.height = h
|
|
188
|
+
|
|
189
|
+
const dctx = displacement.getContext('2d')!
|
|
190
|
+
const hctx = highlight.getContext('2d')!
|
|
191
|
+
|
|
192
|
+
const dImage = dctx.createImageData(w, h)
|
|
193
|
+
const hImage = hctx.createImageData(w, h)
|
|
194
|
+
|
|
195
|
+
const vectors: Array<[number, number]> = []
|
|
196
|
+
let max = 1
|
|
197
|
+
|
|
198
|
+
const light = {
|
|
199
|
+
x: Math.cos((props.glareAngle * Math.PI) / 180),
|
|
200
|
+
y: Math.sin((props.glareAngle * Math.PI) / 180),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (let y = 0; y < h; y++) {
|
|
204
|
+
for (let x = 0; x < w; x++) {
|
|
205
|
+
const dist = distanceToRoundedRectEdge(x, y, w, h, resolvedRadius.value)
|
|
206
|
+
const t = clamp(dist / props.bezel)
|
|
207
|
+
|
|
208
|
+
let vx = 0
|
|
209
|
+
let vy = 0
|
|
210
|
+
let spec = 0
|
|
211
|
+
|
|
212
|
+
if (dist >= 0 && dist < props.bezel) {
|
|
213
|
+
const n = normalFromRoundedRect(x, y, w, h, resolvedRadius.value)
|
|
214
|
+
const slope = derivative(t)
|
|
215
|
+
const bend = refractSlope(slope)
|
|
216
|
+
|
|
217
|
+
const amount =
|
|
218
|
+
bend * props.thickness * (1 - t) * props.refraction
|
|
219
|
+
|
|
220
|
+
vx = -n.x * amount
|
|
221
|
+
vy = -n.y * amount
|
|
222
|
+
|
|
223
|
+
max = Math.max(max, Math.abs(vx), Math.abs(vy))
|
|
224
|
+
|
|
225
|
+
const facing = clamp(n.x * light.x + n.y * light.y, 0, 1)
|
|
226
|
+
spec = Math.pow(facing, 18) * props.specularOpacity
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
vectors.push([vx, vy])
|
|
230
|
+
|
|
231
|
+
const hi = (y * w + x) * 4
|
|
232
|
+
const alpha = Math.round(spec * 255)
|
|
233
|
+
|
|
234
|
+
hImage.data[hi] = 255
|
|
235
|
+
hImage.data[hi + 1] = 255
|
|
236
|
+
hImage.data[hi + 2] = 255
|
|
237
|
+
hImage.data[hi + 3] = alpha
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
vectors.forEach(([vx, vy], index) => {
|
|
242
|
+
const i = index * 4
|
|
243
|
+
|
|
244
|
+
dImage.data[i] = Math.round(128 + (vx / max) * 127)
|
|
245
|
+
dImage.data[i + 1] = Math.round(128 + (vy / max) * 127)
|
|
246
|
+
dImage.data[i + 2] = 128
|
|
247
|
+
dImage.data[i + 3] = 255
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
dctx.putImageData(dImage, 0, 0)
|
|
251
|
+
hctx.putImageData(hImage, 0, 0)
|
|
252
|
+
|
|
253
|
+
mapUrl.value = canvasToUrl(displacement)
|
|
254
|
+
specularUrl.value = canvasToUrl(highlight)
|
|
255
|
+
scale.value = max
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function updateSize() {
|
|
259
|
+
const el = getRootEl()
|
|
260
|
+
if (!el) return
|
|
261
|
+
|
|
262
|
+
width.value = Math.max(1, el.offsetWidth)
|
|
263
|
+
height.value = Math.max(1, el.offsetHeight)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const defaultBg = supportsLiquidGlass
|
|
267
|
+
? 'rgba(255, 255, 255, 0.08)'
|
|
268
|
+
: 'rgba(255, 255, 255, 0.22)'
|
|
269
|
+
|
|
270
|
+
const resolvedRadius = computed(() => {
|
|
271
|
+
if (props.radius !== 32) return props.radius
|
|
272
|
+
const a = props.asProps as Record<string, unknown>
|
|
273
|
+
if (typeof a.rounded === 'string' && a.rounded in roundedMap) {
|
|
274
|
+
return roundedMap[a.rounded]
|
|
275
|
+
}
|
|
276
|
+
return props.radius
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const vuetifyThemeColors = new Set([
|
|
280
|
+
'primary', 'secondary', 'accent', 'info', 'warning', 'error', 'success',
|
|
281
|
+
'surface', 'background',
|
|
282
|
+
])
|
|
283
|
+
|
|
284
|
+
function toTransparent(str: string, alpha: number): string {
|
|
285
|
+
if (vuetifyThemeColors.has(str)) {
|
|
286
|
+
return `rgba(var(--v-theme-${str}), ${alpha})`
|
|
287
|
+
}
|
|
288
|
+
if (str.startsWith('#')) {
|
|
289
|
+
const h = str.replace('#', '')
|
|
290
|
+
const r = parseInt(h.slice(0, 2), 16)
|
|
291
|
+
const g = parseInt(h.slice(2, 4), 16)
|
|
292
|
+
const b = parseInt(h.slice(4, 6), 16)
|
|
293
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
294
|
+
}
|
|
295
|
+
if (str.startsWith('rgb')) {
|
|
296
|
+
return str.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
|
|
297
|
+
}
|
|
298
|
+
return defaultBg
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const glassBgValue = computed(() => {
|
|
302
|
+
if (props.glassBackground !== undefined) return props.glassBackground
|
|
303
|
+
const a = props.asProps as Record<string, unknown>
|
|
304
|
+
if (typeof a.color === 'string') {
|
|
305
|
+
return toTransparent(a.color, 0.6)
|
|
306
|
+
}
|
|
307
|
+
return defaultBg
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const backdropFilter = computed(() => {
|
|
311
|
+
if (supportsLiquidGlass) {
|
|
312
|
+
return `url(#${filterId}) blur(${props.blur}px)`
|
|
313
|
+
}
|
|
314
|
+
return 'blur(12px)'
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const rootStyle = computed(() => ({
|
|
318
|
+
borderRadius: `${resolvedRadius.value}px`,
|
|
319
|
+
...(glassBgValue.value !== undefined
|
|
320
|
+
? { backgroundColor: glassBgValue.value }
|
|
321
|
+
: {}),
|
|
322
|
+
...(glassBgValue.value !== undefined
|
|
323
|
+
? {
|
|
324
|
+
boxShadow: [
|
|
325
|
+
'inset 0 0 0 1px rgba(255, 255, 255, 0.28)',
|
|
326
|
+
'0 18px 48px rgba(0, 0, 0, 0.18)',
|
|
327
|
+
].join(', '),
|
|
328
|
+
}
|
|
329
|
+
: {}),
|
|
330
|
+
...(glassReady.value && !isNested
|
|
331
|
+
? {
|
|
332
|
+
backdropFilter: backdropFilter.value,
|
|
333
|
+
WebkitBackdropFilter: backdropFilter.value,
|
|
334
|
+
}
|
|
335
|
+
: {}),
|
|
336
|
+
...(glassReady.value && isNested && !supportsLiquidGlass
|
|
337
|
+
? {
|
|
338
|
+
backdropFilter: backdropFilter.value,
|
|
339
|
+
WebkitBackdropFilter: backdropFilter.value,
|
|
340
|
+
}
|
|
341
|
+
: {}),
|
|
342
|
+
}))
|
|
343
|
+
|
|
344
|
+
let ro: ResizeObserver | null = null
|
|
345
|
+
|
|
346
|
+
onMounted(async () => {
|
|
347
|
+
await nextTick()
|
|
348
|
+
|
|
349
|
+
if (supportsLiquidGlass) {
|
|
350
|
+
updateSize()
|
|
351
|
+
buildMaps()
|
|
352
|
+
await nextTick()
|
|
353
|
+
glassReady.value = true
|
|
354
|
+
|
|
355
|
+
ro = new ResizeObserver(() => {
|
|
356
|
+
updateSize()
|
|
357
|
+
buildMaps()
|
|
358
|
+
glassReady.value = true
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const el = getRootEl()
|
|
362
|
+
if (el) ro.observe(el)
|
|
363
|
+
} else {
|
|
364
|
+
glassReady.value = true
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
watch(
|
|
369
|
+
() => [
|
|
370
|
+
resolvedRadius.value,
|
|
371
|
+
props.bezel,
|
|
372
|
+
props.thickness,
|
|
373
|
+
props.refraction,
|
|
374
|
+
props.surface,
|
|
375
|
+
props.specularOpacity,
|
|
376
|
+
props.glareAngle,
|
|
377
|
+
],
|
|
378
|
+
() => {
|
|
379
|
+
if (supportsLiquidGlass) {
|
|
380
|
+
buildMaps()
|
|
381
|
+
}
|
|
382
|
+
glassReady.value = true
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
onBeforeUnmount(() => {
|
|
387
|
+
ro?.disconnect()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
</script>
|
|
391
|
+
|
|
392
|
+
<template>
|
|
393
|
+
<component
|
|
394
|
+
:is="as"
|
|
395
|
+
ref="root"
|
|
396
|
+
class="liquid-glass"
|
|
397
|
+
v-bind="asProps"
|
|
398
|
+
:style="rootStyle"
|
|
399
|
+
>
|
|
400
|
+
<svg
|
|
401
|
+
v-if="supportsLiquidGlass"
|
|
402
|
+
class="liquid-glass__svg"
|
|
403
|
+
aria-hidden="true"
|
|
404
|
+
>
|
|
405
|
+
<defs>
|
|
406
|
+
<filter
|
|
407
|
+
:id="filterId"
|
|
408
|
+
color-interpolation-filters="sRGB"
|
|
409
|
+
filterUnits="userSpaceOnUse"
|
|
410
|
+
x="0"
|
|
411
|
+
y="0"
|
|
412
|
+
width="10000"
|
|
413
|
+
height="10000"
|
|
414
|
+
>
|
|
415
|
+
<feImage
|
|
416
|
+
:href="mapUrl"
|
|
417
|
+
x="0"
|
|
418
|
+
y="0"
|
|
419
|
+
:width="width"
|
|
420
|
+
:height="height"
|
|
421
|
+
result="displacement_map"
|
|
422
|
+
/>
|
|
423
|
+
|
|
424
|
+
<feDisplacementMap
|
|
425
|
+
in="SourceGraphic"
|
|
426
|
+
in2="displacement_map"
|
|
427
|
+
:scale="scale"
|
|
428
|
+
xChannelSelector="R"
|
|
429
|
+
yChannelSelector="G"
|
|
430
|
+
result="refracted"
|
|
431
|
+
/>
|
|
432
|
+
|
|
433
|
+
<feImage
|
|
434
|
+
:href="specularUrl"
|
|
435
|
+
x="0"
|
|
436
|
+
y="0"
|
|
437
|
+
:width="width"
|
|
438
|
+
:height="height"
|
|
439
|
+
result="specular"
|
|
440
|
+
/>
|
|
441
|
+
|
|
442
|
+
<feBlend in="refracted" in2="specular" mode="screen" />
|
|
443
|
+
</filter>
|
|
444
|
+
</defs>
|
|
445
|
+
</svg>
|
|
446
|
+
|
|
447
|
+
<slot />
|
|
448
|
+
</component>
|
|
449
|
+
</template>
|
|
450
|
+
|
|
451
|
+
<style scoped>
|
|
452
|
+
.liquid-glass {
|
|
453
|
+
position: relative;
|
|
454
|
+
isolation: isolate;
|
|
455
|
+
overflow: hidden;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.liquid-glass__svg {
|
|
459
|
+
position: absolute;
|
|
460
|
+
inset: 0;
|
|
461
|
+
width: 100%;
|
|
462
|
+
height: 100%;
|
|
463
|
+
pointer-events: none;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
@supports not ((backdrop-filter: url("#x"))) {
|
|
467
|
+
.liquid-glass {
|
|
468
|
+
backdrop-filter: blur(12px);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
</style>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Plugin } from 'vue'
|
|
2
|
+
import Liqvued from '@liqvued/Liqvued.vue'
|
|
3
|
+
import type { Surface } from './types'
|
|
4
|
+
|
|
5
|
+
const LiqvuedPlugin: Plugin = {
|
|
6
|
+
install(app) {
|
|
7
|
+
app.component('Liqvued', Liqvued)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { Liqvued }
|
|
12
|
+
export type { Surface }
|
|
13
|
+
export default LiqvuedPlugin
|
package/src/types.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type Surface = 'convex' | 'concave' | 'lip'
|