@daviduo/liquid-glass-web
v0.1.0
Published
Liquid glass (real refraction, not blur) for the web — a Vue 3 composable/directive port of Telegram Android's blur3 shader, driven by an SVG feDisplacementMap on backdrop-filter.
Maintainers
Readme
liquid-glass-web
Real liquid glass (optical refraction, not just blur) for the web — a Vue 3 composable + directive. Port of the technique used by the Telegram Android client (
org.telegram.ui.Components.blur3).
Apply a physically-motivated glass refraction to buttons, cards, panels — anything — and it bends whatever is behind the element like a real lens with thickness.
TL;DR
npm i @daviduo/liquid-glass-web<script setup lang="ts">
import { ref } from "vue";
import { useLiquidGlass } from "@daviduo/liquid-glass-web";
const el = ref<HTMLElement>();
useLiquidGlass(el, { radius: 20, thickness: 14, refractIntensity: 1.2, blur: 2 });
</script>
<template>
<!-- needs something BEHIND it; the tint is plain CSS on top -->
<button ref="el" style="background: rgba(255,255,255,.12); border-radius: 20px">
Glass
</button>
</template>What it actually is (and isn't)
Common misconception: "glass = blurred background". That's frosted glass and it's only half of it. Telegram's real effect is refraction: the panel behaves like a lens — flat in the center (no distortion), curved at the edges (the background gets pushed / bent), which is what gives the liquid, three-dimensional look.
backdrop-filter: blur() alone can't do that. This library does.
How it works
On Android, Telegram runs an AGSL fragment shader over the captured background: for every
pixel inside a rounded-rect it builds a bulged glass surface from a Signed Distance
Function (SDF), computes the surface normal, applies Snell's law (refract()), and
samples the background at the shifted coordinate.
The web can't sample arbitrary backdrop pixels inside a generic fragment shader. So this library splits the work:
generateGlassMap() buildSvgFilter() CSS
┌────────────────────┐ href ┌───────────────────────────┐ url() ┌───────────────┐
│ same math as the │ ───────► │ <feImage> displacement map │ ───────► │ backdrop- │
│ AGSL shader: │ │ <feDisplacementMap │ │ filter:url(#) │
│ SDF + Snell refract │ │ in=backdrop in2=map> │ │ → refraction │
│ → per-pixel offset │ │ [+ feGaussianBlur] │ │ of backdrop │
│ packed in R/G │ │ [+ feColorMatrix saturate] │ │ │
└────────────────────┘ └───────────────────────────┘ └───────────────┘generateGlassMapre-implements the shader'smain()in TypeScript. For each pixel it computes the exact refraction offset(dx, dy)and encodes it into a PNG displacement map — offset X → red channel, offset Y → green (neutral 128 = no shift).buildSvgFilterwraps that map in an SVG<filter>:feImageloads the map,feDisplacementMapuses it to shift the backdrop (in="SourceGraphic"= the backdrop when the filter is used asbackdrop-filter). OptionalfeGaussianBlur(frosted) andfeColorMatrix(saturation, like Telegram'ssaturationX3).- The composable sets
backdrop-filter: url(#thatFilter)on your element. The browser does the sampling + displacement natively on the GPU.
Optical result = identical to the Telegram shader; the map is precomputed once per size/parameter set instead of per frame.
Faithful bits ported 1:1
sdfRect()rounded-rect SDF, including the per-quadrant corner-radius selection and the Android uniform orderradius = (br, tr, bl, tl).- Normal build (
n_cos/n_sinthickness profile),refract(I, N, eta), refracted path length(h + 8*thickness) / -refract_vec.z, andrefractIntensitymultiplier. - Radius clamp to
min(w,h)/2(so pills/circles keep refracting — an unclamped radius degenerates the SDF and kills the effect).
Install
npm i @daviduo/liquid-glass-web
# or straight from GitHub:
npm i github:0Daviduo0/liquidGlass-webRequires Vue 3 (^3.3.0) as a peer dependency.
Usage
Composable — useLiquidGlass
import { useLiquidGlass } from "@daviduo/liquid-glass-web";
const handle = useLiquidGlass(elRef, {
radius: 20, // px, or {tl,tr,br,bl}; default: reads computed border-radius
thickness: 12, // virtual glass thickness (px); higher = wider edge refraction
refractIndex: 1.5, // ~1.5 = glass
refractIntensity: 1, // 0 = flat, 1 = full
blur: 0, // gaussian blur on the backdrop (px), 0 = crisp
saturate: 1, // backdrop saturation (Telegram uses ~3)
pixelRatio: 1, // map density; devicePixelRatio for sharper edges
autoResize: true, // regenerate map on resize (ResizeObserver)
enabled: true, // toggle at runtime
});
handle.update(); // force rebuild
handle.destroy(); // remove filter + stylesOptions can be a reactive object / ref / getter — change them and the effect updates (deep-watched). The target can be a ref, getter or element.
Directive — v-liquid-glass
import { createApp } from "vue";
import { vLiquidGlass } from "@daviduo/liquid-glass-web";
app.directive("liquid-glass", vLiquidGlass);<div v-liquid-glass="{ radius: 22, thickness: 14, blur: 3 }">card</div>Framework-agnostic core
No Vue required — use the primitives directly:
import { generateGlassMap, buildSvgFilter } from "@daviduo/liquid-glass-web";
const map = generateGlassMap({ width: 320, height: 120, radius: 24, thickness: 14, refractIndex: 1.5, refractIntensity: 1 });
const svg = buildSvgFilter("my-glass", map, { blur: 2, saturate: 2 });
document.body.appendChild(svg);
el.style.backdropFilter = "url(#my-glass)";Options reference
| Option | Type | Default | Meaning |
| ------------------ | --------------------------------- | ------- | -------------------------------------------------- |
| radius | number \| {tl,tr,br,bl} | auto | Corner radii (px). Auto reads border-radius. |
| thickness | number | 12 | Virtual thickness → edge refraction width. |
| refractIndex | number | 1.5 | Refractive index (>1). 1.5 = glass. |
| refractIntensity | number | 1 | Distortion multiplier. 0 disables. |
| blur | number | 0 | Gaussian blur on the backdrop (frosted). |
| saturate | number | 1 | Backdrop saturation (vivid Telegram look). |
| pixelRatio | number | 1 | Map density. Use devicePixelRatio for sharpness. |
| autoResize | boolean | true | Rebuild map on element resize. |
| enabled | boolean | true | Enable/disable at runtime. |
Important rules
- There must be something behind the element (image, text, gradient). Refraction acts on the backdrop; on a flat solid background you'll see nothing.
- The tint is CSS, not part of the filter — set
background: rgba(...)on the element. It sits over the refracted backdrop (src-over compositing, same as the shader). - The map depends on size;
autoResizeregenerates it. Moving the element (drag/scroll) costs nothing — the browser recomputes the backdrop automatically. - Keep
radiussensible; it's internally clamped tomin(w,h)/2.
Browser support
backdrop-filter: url(#svgFilter) is the gating feature:
- ✅ Chromium (Chrome / Edge / Brave / Arc): full support.
- ⚠️ Firefox: SVG filters in
backdrop-filterwork, rendering less consistent. - ❌ Safari: does not support
url()inbackdrop-filter. The composable feature-detects and skips applying the filter, so the element keeps its own CSS background/blur (graceful frosted fallback — no refraction).
Difference vs Android: there the shader runs per-frame on a RenderNode; here the map is precomputed (static for a given size + params) and the browser's compositor does the refraction — same optics, one-time generation cost.
Playground
git clone https://github.com/0Daviduo0/liquidGlass-web.git
cd liquidGlass-web
npm install
npm run dev # http://localhost:5174The playground (playground/) has a draggable glass shape over photos, shape presets
(Card / Pill / Circle / Square / Bar / Button) and live sliders for every parameter.
Build
npm run build # → dist/ (ESM + UMD + bundled .d.ts)Project layout
src/
liquidGlass.ts core, framework-agnostic (generateGlassMap, buildSvgFilter)
useLiquidGlass.ts Vue 3 composable
vLiquidGlass.ts Vue directive
index.ts public exports
playground/ demo app (Vite)License
MIT — see LICENSE.
Technique derived (independently re-implemented, not copied) from DrKLO/Telegram's
blur3 liquid glass (GPLv2). Attribution kept in the LICENSE file.
