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 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
+ [![npm](https://img.shields.io/npm/v/liqvued.svg)](https://www.npmjs.com/package/liqvued)
4
+ [![npm](https://img.shields.io/npm/dw/liqvued.svg)](https://www.npmjs.com/package/liqvued)
5
+ [![GitHub](https://img.shields.io/github/license/ivoyt/liqvued)](./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)
@@ -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*/
@@ -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
+ }
@@ -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'