@arcon.mobi/malic-ui
Malic UI
Vue 3 components for Apple-flavored interface surfaces: refractive Liquid Glass, Figma-smoothed squircles, and layered Liquid Glass icons.
Three families, one package:
| Components | What it is | |
|---|---|---|
| Surfaces | LiquidGlass, useLiquidGlass |
An SVG refraction filter over whatever the page has painted behind an element |
| Shapes | Squircle, squirclePath, squircleStyle |
Continuous-curvature corners, plus the shadow, border and glass a border-radius would have got |
| Icons | LiquidGlassIcon, LiquidGlassLayer, FlatIconLayer, LiquidGlassGroup |
Declarative SVG composited through WebGL2 into an exportable icon raster |
The first two are page chrome — real DOM, real backdrop, live at 60fps. The third is an offline renderer whose output happens to be a canvas. They share a vocabulary and a stylesheet, not a pipeline.
Install
npm install malic-ui vue
Import the package stylesheet once in your application entry:
import 'malic-ui/style.css'
Vue 3.5 or newer is required. The package has no other runtime dependencies — no Tailwind, no VueUse.
Surfaces
Squircle
A rounded rectangle with Figma-style corner smoothing, plus the decoration a rounded rectangle would have got from border-radius, border, box-shadow and backdrop-filter.
<script setup lang="ts">
import { Squircle } from 'malic-ui'
import 'malic-ui/style.css'
</script>
<template>
<Squircle
class="p-6"
:appearance="{ radius: 24, smoothing: 0.8 }"
glass="md"
:liquid-glass="true"
>
<h2>Refracting the page behind it</h2>
</Squircle>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
as |
string | Component |
'div' |
Root element. Must be single-root and block-level |
appearance |
SquircleAppearance |
{} |
Radius, smoothing, border width and border color. Each field accepts a literal or MotionValue-like source |
radius / smoothing |
shape values | — | 4.x compatibility aliases; the matching appearance field wins |
borderWidth / borderColor |
border values | — | 4.x compatibility aliases; the matching appearance field wins |
surfaceClass |
string |
— | Classes for the clipped surface layer. Backgrounds go here |
clipContent |
boolean |
false |
Clip the slot to the shape too |
contentClass |
string |
— | Classes for the clipped content layer. clipContent only |
glass |
'sm' | 'md' | 'lg' |
— | Backdrop blur and saturation over a white gradient |
liquidGlass |
boolean | LiquidGlassOptions |
false |
Add refraction to the surface |
shadow |
ShadowPreset | layers | null |
'glass' with glass |
Presets sm/md/lg/xl/glass, or raw box-shadow-style layers |
highlight |
boolean | string |
on with glass | Inset hairline; a string is its colour |
borderStyle |
SquircleBorderStyle | SquircleBorderSideStyles |
'solid' |
solid, dashed or dotted; the tuple is [top, right, bottom, left] and also accepts none |
rightInset |
number | MotionValueLike<number> |
0 |
Move the right edge in without changing layout width |
Exposes rebuild(), which regenerates the refractive maps — see Rebuilding after a transform.
Why it is layered rather than one clipped div
clip-path clips the element's entire subtree, and CSS decoration is shaped by border-radius, which knows nothing about the path. So none of it survives being put on a clipped box: an outer box-shadow paints outside the border box and is clipped away entirely, a CSS border is shaped like the rectangle and gets sliced diagonally at each corner, and any popover a child renders inline is cut off at the edge.
The two obvious repairs both fail. Wrapping in a parent with filter: drop-shadow() shadows the clipped silhouette correctly, but a filter establishes a Backdrop Root — so backdrop-filter on anything inside it samples an empty backdrop and the glass turns flat; shadow would cost glassmorphism. And blurring a clipped caster does nothing, because a CSS filter is applied before clip-path: the blur is cut back to a hard edge.
So decoration is split into sibling layers, each in whichever technology shapes it correctly, and only the surface is clipped:
| Layer | What it is | Why there |
|---|---|---|
| shadow | blurred SVG paths, behind | outside the box, and a sibling — no ancestor filter, so glass survives |
| surface | clipped div or LiquidGlass |
the one place backdrop-filter can be clipped to the shape |
| strokes | stroked SVG paths, in front | a stroke follows the real path; CSS borders can't |
| content | the slot, unclipped, in flow | so children can render menus past the edge |
Three consequences worth knowing before the first surprise:
- Backgrounds go on
surfaceClass, notclass.classlands on the unclipped root, so a background there paints a plain rectangle behind the squircle and its corners stick out. - Fade a glass squircle with
--squircle-opacity, notopacity. Anopacitybelow 1 anywhere above the surface is a Backdrop Root, so the glass reads flat for the whole animation and the blur snaps in on the frame it lands. The variable fades each layer instead of the group, so the surface keeps its own live backdrop; it inherits, so it can go on whatever element is actually being animated. Use--squircle-group-opacityon a container fading several at once — the two multiply, where a container reusing the inner name would just be shadowed by it. A squircle with no glass has no backdrop to lose and can be faded either way. - Don't put a CSS
borderon the root. The surface is sized to the padding box, so a border would offset it. UseborderWidth.
Content is not clipped by default; padding keeps it inside. clipContent opts in, for content that paints to the edge anyway — a map canvas, a full-bleed image. It moves the slot into its own clipped layer between surface and strokes rather than clipping the root, so the shadow still survives; the content layer is then absolutely positioned, which means the root has to be sized by layout and padding on the root no longer insets it.
Animated geometry and appearance
appearance.radius, appearance.smoothing, appearance.borderWidth and
appearance.borderColor accept either their literal value or any object with
Motion's read-only get()/on('change') interface. The decorative layers own
those subscriptions in a separate render boundary, so animation frames redraw
the SVG geometry without evaluating the default slot. malic-ui does not depend
on Motion or own the transition. rightInset accepts the same kind of source
outside appearance, since it changes the visible viewport rather than the
surface's style.
<script setup lang="ts">
import { animate, useMotionValue, useTransform } from 'motion-v'
import { Squircle, type SquircleAppearance } from 'malic-ui'
const progress = useMotionValue(0)
const appearance: SquircleAppearance = {
radius: useTransform(progress, [0, 1], [12, 28]),
smoothing: useTransform(progress, [0, 1], [0.4, 0.8]),
borderWidth: useTransform(progress, [0, 1], [1, 3]),
borderColor: useTransform(
progress,
[0, 1],
['rgba(0, 0, 0, 0.08)', 'rgba(0, 129, 104, 0.8)'],
),
}
const expand = () => animate(progress, 1, { type: 'spring', stiffness: 500, damping: 40 })
</script>
<template>
<Squircle :appearance="appearance" @click="expand">…</Squircle>
</template>
Keep the appearance object stable and pass Motion values themselves. Mirroring
their latest value into Vue state would put the parent back on the per-frame
render path. Animated radii should publish numeric pixel values; radius tokens
remain useful as static appearance values. With refractive liquidGlass, the
SVG silhouette follows the animation live and the expensive refractive maps
rebuild after the shape has been still for 150 ms.
Border style, width and color may each use one value around the whole outline or
four values in CSS order. Scalar properties repeat on every side. Each corner's
miter follows the adjacent widths so their inner edges meet without a notch; if
one side is omitted with none or a zero tuple width, its neighbor extends all
the way through that corner:
<Squircle
:appearance="{
borderWidth: [2, 4, 6, 0],
borderColor: ['red', 'blue', 'green', 'transparent'],
}"
:border-style="['solid', 'dashed', 'dotted', 'none']"
/>
Squircles without the component
squircleStyle returns a cached clip-path for a squircle whose size is known at author time — an icon chip, a swatch — skipping the ResizeObserver, the second render pass measuring forces, and the per-instance <clipPath>:
import { squircleStyle } from 'malic-ui'
const chip = squircleStyle({ width: 40, height: 40, radius: 'lg' })
// → { clipPath: 'path("M …")' }
It does clip children, which the component deliberately does not, so keep it to surfaces whose content stays inside. squirclePath({ width, height, radius, smoothing, x?, y? }) returns the raw d if you want to draw the shape yourself; radius may be one px value or [topLeft, topRight, bottomRight, bottomLeft]. Every command after the opening M is relative, so x/y let several shapes share one user space. radiusToPx, radiiToPx and RADIUS_TOKENS resolve Tailwind's rounded-* keys.
LiquidGlass
A refractive glass surface over whatever the page has painted behind it. The element keeps its own background, padding and children; all this adds is a generated SVG filter in backdrop-filter, so the backdrop bends through a bezel that follows the element's silhouette.
<script setup lang="ts">
import { LiquidGlass } from 'malic-ui'
</script>
<template>
<LiquidGlass
class="glass-nav"
:options="{ bezelWidth: 20, glassThickness: 1, refractiveIndex: 1.5 }"
>
<p>Your content here</p>
</LiquidGlass>
</template>
<style scoped>
.glass-nav {
border-radius: 9999px;
padding: 1rem;
background: rgb(255 255 255 / 0.5);
/* Fallback for everything that is not Chromium. */
backdrop-filter: blur(1rem);
}
</style>
| Prop | Type | Default | Description |
|---|---|---|---|
options |
LiquidGlassOptions |
{} |
See below |
tag |
string |
'div' |
Element to render |
disabled |
boolean |
false |
Same as options.disabled; either source turns it off |
| Option | Type | Default | Description |
|---|---|---|---|
bezelWidth |
number |
20 |
Width of the refractive bezel in px |
glassThickness |
number |
0.8 |
Thickness of the virtual glass surface |
refractiveIndex |
number |
1.5 |
Refractive index — glass is 1.5, water 1.33 |
specularOpacity |
number |
0.4 |
Specular rim highlight opacity, 0–1 |
specularAngle |
number |
-60 |
Light source angle in degrees |
specularPower |
number |
6 |
Specular sharpness exponent |
blurAmount |
number |
0.5 |
Gaussian blur in px after refraction |
shape |
GlassShape |
{ type: 'rounded-rect' } |
Silhouette to build the bezel around |
disabled |
boolean |
false |
Turn the effect off entirely |
Exposes rebuild().
Shape: the bezel has to match the clip
The bezel is drawn per pixel from how far that pixel is from the silhouette edge and which way the edge faces, so it only lines up if it is built from the same shape the element is clipped to. Get that wrong and the error shows exactly where a corner is: the bezel bulges past the clip on the diagonal and leaves a flat, unrefracted band along each side.
type GlassShape =
// A rounded rectangle. Omit `radius` to read the element's own border-radius.
| { type: 'rounded-rect'; radius?: number }
// A Figma-smoothed squircle, matching `Squircle` and `squircleStyle`.
| { type: 'squircle'; radius?: number | RadiusToken; smoothing?: number }
// Any outline, in the element's user space with the origin top-left.
| { type: 'path'; d: string }
A rounded-rect has a closed-form distance field and is the fast path — it is also the default, read from the element's computed border-radius (percentages resolved against the box, clamped to a pill). A squircle or a path has no closed form, so the outline is flattened and every pixel inside the bezel gets its exact distance to the nearest segment; the cost scales with perimeter × bezel width rather than with area.
Squircle wires this up for you. It hands its surface the very d it is clipping with, so the two can never drift:
<!-- The bezel refracts around the smoothed corner, not a circular one. -->
<Squircle :appearance="{ radius: 32, smoothing: 0.8 }" :liquid-glass="true">…</Squircle>
You only need shape by hand when you clip an element yourself.
Where the glass stops
Chromium clips a backdrop-filter to the element's border box and its
border-radius, and to nothing else. A clip-path is not consulted — not on
the element, not on an ancestor — and a plain blur() spills exactly as far as
a filter reference does. So a squircle-clipped surface would paint its filtered
backdrop right out to the corners of its bounding rectangle, outside the shape,
where the surface has no background of its own to cover it.
The filter therefore stops itself: a third generated image holds the silhouette,
and the whole chain is composited in it. Where the output is transparent the
unfiltered backdrop shows through, which is what the clip would have given. It
works for any silhouette rather than the one shape CSS can describe, and it
costs one more image per rebuild.
Nothing about this needs configuring — the mask comes from the same distance field as the other two maps, so it always agrees with the bezel.
Turning it off
disabled takes the effect off the element without unmounting or restyling it: nothing is generated, nothing is observed, and no inline backdrop-filter is written, so the element falls back to whatever CSS it carries. Bind it to a breakpoint or a preference rather than rendering a second copy of the element or beating the inline style with !important.
<script setup>
import { useMediaQuery } from '@vueuse/core'
const isDesktop = useMediaQuery('(min-width: 768px)')
</script>
<template>
<LiquidGlass :disabled="!isDesktop" class="card">…</LiquidGlass>
</template>
Toggling it back on regenerates the maps at the size the element has by then — but the last generated pair is cached, so if nothing that feeds them has changed (size, shape, or any option) it is reused rather than redrawn. Generating them is a per-pixel pass plus two PNG encodes, which is what makes that cache the thing that lets disabled be driven from something as hot as a scroll threshold:
<script setup>
import { useWindowScroll } from '@vueuse/core'
const { y } = useWindowScroll()
const atTop = computed(() => y.value < 10)
</script>
<template>
<!-- Bare over the hero, glass once the page moves. -->
<LiquidGlass :disabled="atTop" class="nav">…</LiquidGlass>
</template>
Rebuilding after a transform
The element is watched with a ResizeObserver, which only fires on layout changes — while the size the maps are built from is the rendered one. An element whose rendered size is driven by a transform (a reveal animation that scales it in, say) therefore has to say when it has settled. Squircle forwards the same method.
<script setup>
import { useTemplateRef } from 'vue'
const glass = useTemplateRef('glass')
</script>
<template>
<motion.div
:initial="{ scale: 0.86 }"
:animate="{ scale: 1 }"
@animation-complete="glass?.rebuild()"
>
<LiquidGlass ref="glass">…</LiquidGlass>
</motion.div>
</template>
useLiquidGlass
For custom layouts, drive the composable directly and wire up the SVG filter and backdrop-filter yourself. It accepts a plain object, a ref, or a getter — pass one of the latter two if any option has to change after mount:
import { useLiquidGlass } from 'malic-ui'
const {
filterId, glassRef, dispUrl, specUrl, scale, filterWidth, filterHeight,
isReady, isSupported, blurAmount, isDisabled, rebuild,
} = useLiquidGlass(() => ({ bezelWidth: 20, disabled: !isDesktop.value }))
Browser support
SVG filter references in backdrop-filter are Chromium-only. Support is detected once at mount from two signals, because neither is enough alone: the property has to parse a url() reference (which rules out Safari 17 and earlier, where only -webkit-backdrop-filter ever existed), and the engine has to be Chromium (which rules out Firefox, where the reference parses and is then ignored). Client hints answer the second question where they exist, with window.chrome and the user agent as fallbacks — window.chrome alone is not enough, since plain Chromium builds don't expose it and support the effect perfectly well.
Everywhere the answer is no, nothing is generated and no inline filter is written, so the element keeps whatever CSS fallback it carries. Give every glass surface a plain backdrop-filter: blur() in CSS; Squircle's glass presets already do.
Liquid Glass icons
Consumers author normal SVG geometry inside FlatIconLayer and LiquidGlassLayer. Malic UI rasterizes those live sources, composites the icon through WebGL2 when available, and automatically falls back to Canvas 2D. The visible result is a canvas-backed raster image and can be exported as PNG.
Basic usage
<script setup lang="ts">
import {
FlatIconLayer,
LiquidGlassIcon,
LiquidGlassLayer,
} from 'malic-ui'
import 'malic-ui/style.css'
</script>
<template>
<LiquidGlassIcon
view-box="0 0 256 256"
:size="256"
:resolution="1024"
label="Add"
:surface="{
type: 'linear-gradient',
angle: 90,
stops: [
{ offset: 0, color: '#ff6b6b' },
{ offset: 1, color: '#5f67ff' },
],
}"
>
<template #default>
<FlatIconLayer :opacity="0.35">
<template #default>
<circle cx="128" cy="128" r="96" fill="#ffffff" />
</template>
</FlatIconLayer>
<LiquidGlassLayer
:material="{
blur: 0.35,
frost: 0.25,
translucency: 0.46,
specular: 0.8,
shadow: { opacity: 0.5, kind: 'layer-color' },
}"
>
<template #default>
<path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8Z" />
</template>
</LiquidGlassLayer>
</template>
</LiquidGlassIcon>
</template>
Shared SVG definitions
Put gradients, masks, filters, and clip paths used by more than one layer in the root defs slot. IDs and all local references are scoped per icon and layer during rasterization.
<LiquidGlassIcon :surface="surface">
<template #defs>
<linearGradient id="shared-paint">
<stop offset="0" stop-color="#ffffff" />
<stop offset="1" stop-color="#8ba7ff" />
</linearGradient>
</template>
<template #default>
<FlatIconLayer>
<template #default>
<circle cx="128" cy="128" r="80" fill="url(#shared-paint)" />
</template>
</FlatIconLayer>
</template>
</LiquidGlassIcon>
Export
The root component exposes rendering and canvas export methods. Exports wait for the newest requested reactive frame before reading the canvas.
<script setup lang="ts">
import { ref } from 'vue'
import type { LiquidGlassIconExpose } from 'malic-ui'
const icon = ref<LiquidGlassIconExpose | null>(null)
async function download() {
const blob = await icon.value?.toBlob('image/png')
if (!blob) return
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'icon.png'
anchor.click()
URL.revokeObjectURL(url)
}
</script>
toDataURL(type?, quality?), render(), and getCanvas() are also available.
LiquidGlassIcon
Props
| Prop | Type | Default |
|---|---|---|
surface |
SurfacePaint |
Required |
viewBox |
string |
"0 0 256 256" |
size |
number | string |
256 |
resolution |
number |
1024, clamped to 64…4096 |
appearance |
IconAppearance (six renditions; see below) |
'default' |
tint |
{ color: string; strength?: number } |
Purple at 0.65 |
colorSpace |
'srgb' | 'display-p3' |
'srgb' |
lightAngle |
number |
135 degrees |
label |
string |
Decorative when omitted |
specularPolicy |
'all' | 'topmost' |
'all' |
Number size values are CSS pixels. String values such as "12rem" pass through to the wrapper.
IconAppearance is 'default' | 'dark' | 'clear-light' | 'clear-dark' | 'tinted-light' | 'tinted-dark'. The legacy 'clear' spelling remains accepted as an alias for 'clear-light'. Clear renditions retain refraction, frost, and edge light while contributing no opaque gray surface; tinted renditions use the root tint. Use colorSpace="display-p3" with CSS colors such as color(display-p3 1 0.25 0.1) for wide-gamut rasterization and compositing.
Events
| Event | Meaning |
|---|---|
ready |
First successful frame committed |
rendered |
A current frame committed, with backend, duration, and resolution |
backend-change |
WebGL activation, fallback, loss, or restoration |
error |
Typed validation, rasterization, WebGL, Canvas, or export failure |
The last successful frame remains visible if a newer frame fails.
LiquidGlassGroup
Wrap layers in LiquidGlassGroup to treat them as one glass object, mirroring Icon Composer's groups.
<LiquidGlassGroup :material="{ translucency: 0.5, specular: 1 }" lighting="combined">
<LiquidGlassLayer><template #default><circle cx="100" cy="128" r="60" /></template></LiquidGlassLayer>
<LiquidGlassLayer><template #default><circle cx="156" cy="128" r="60" /></template></LiquidGlassLayer>
</LiquidGlassGroup>
| Prop | Type | Default |
|---|---|---|
id |
string |
SSR-stable generated ID |
material |
GlassMaterial |
Package default material |
lighting |
'combined' | 'individual' |
'combined' |
opacity |
number |
1 |
blend |
BlendMode |
'normal' |
With combined lighting, glass-enabled member layers are composited together and the group material is applied once to the union silhouette — one rim highlight, one shadow, and no seams where shapes overlap. With individual, each glass member receives its own lighting pass. The material always belongs to the group, matching the Icon Composer schema; member material values inside a group are ignored. Group opacity and blend are applied once to the finished group, while flat members remain ordinary artwork. Groups cannot be nested, IDs must be unique, members must occupy one contiguous z-order block, and an icon may contain at most four groups.
Layer props
Both layer components accept:
| Prop | Type | Default |
|---|---|---|
id |
string |
SSR-stable generated ID |
position |
{ x: number; y: number } |
View-box center |
scale |
number |
1 |
opacity |
number |
1, clamped to 0…1 |
blend |
BlendMode |
'normal' |
fill |
LayerPaint |
{ type: 'source' } |
visible |
boolean |
true |
appearances |
LayerAppearanceOverrides |
None |
LiquidGlassLayer additionally accepts material: GlassMaterial. FlatIconLayer deliberately has no material prop and bypasses all glass passes.
Positions use root view-box units. Scale is centered on the full view box. SVG transform attributes inside the slot remain part of the authored geometry.
appearances specializes fill, opacity, blend, or hidden for any of the six renditions without duplicating geometry:
<LiquidGlassLayer
:appearances="{
dark: { opacity: 0.75 },
'tinted-light': { fill: { type: 'solid', color: '#fff' }, blend: 'screen' },
'clear-dark': { hidden: true },
}"
/>
Paint and material types
type SurfacePaint =
| { type: 'solid'; color: string }
| { type: 'linear-gradient'; angle?: number; stops: readonly ColorStop[] }
type LayerPaint =
| { type: 'source' }
| { type: 'solid'; color: string }
| { type: 'linear-gradient'; angle?: number; stops: readonly ColorStop[] }
interface GlassMaterial {
blur?: number
frost?: number
translucency?: number
specular?: false | number
shadow?: false | {
opacity?: number
kind?: 'neutral' | 'layer-color' | 'none'
}
darkening?: number
monochrome?: number
}
Material values use the 0…1 range. blur controls backdrop diffusion while frost independently controls optical scattering/milkiness, allowing the parity harness to calibrate each response without conflating them. Defaults are blur: 0.25, frost: 0.25, translucency: 0.5, specular: 1, and a neutral shadow at 0.5. neutral is a soft black drop, layer-color blurs the artwork's own per-pixel colors onto the backdrop, and none disables the shadow. The deprecated { strength, type: 'chromatic' } form remains accepted and maps to { opacity, kind: 'layer-color' }. Invalid numeric values are normalized with development warnings. Invalid paint definitions, duplicate layer/group IDs, missing parent context, and unsafe SVG resources surface typed errors.
SVG authoring rules
- Supply geometry such as
<path>,<circle>, and<g>—not a nested root<svg>. - Prefer SVG presentation attributes or inline styles. Common inherited presentation properties and
currentColorare resolved for standalone rasterization. - Shared definitions belong in the root
defsslot. Layer-local<defs>may stay inside a layer. - Reactive attributes, nested children,
v-if,v-for, and keyed moves are observed automatically. Source DOM order is the z-order. - External
<image>sources must be same-origin,data:, orblob:URLs. External<use>references are rejected. - Empty layers render transparently.
Rendering behavior
Malic UI first attempts WebGL2 and uses a bounded shared renderer pool across component instances. Jobs sharing a resolution are serialized and copied into instance-owned scratch canvases before the lease is released. If WebGL2 is unavailable or its context is lost, rendering continues through Canvas 2D.
The WebGL2 glass material builds a signed distance field with a GPU jump-flood pass. The nearest silhouette coordinate drives a scale-independent spherical edge profile: the backdrop refracts through it with slight chromatic dispersion, a rim highlight follows the edges facing the light, and a weaker highlight appears opposite. This remains stable at corners, thin shapes, disconnected geometry, and production resolution. Backdrop blur uses a bounded dual-Kawase downsample/upsample pyramid, avoiding stretched sampling taps and banding at high blur values; its radius mapping lives in blurPyramid.ts for harness calibration. Blur and compositing run in linear light, and frost is applied as a separate scattering response. Artwork colors are otherwise passed through faithfully. The Canvas 2D fallback preserves the same composition, frost, shadow, and edge-lighting model with a native filter blur and no chromatic refraction.
The declared surface is the backdrop sampled by the glass pipeline. The component does not sample arbitrary HTML or imagery behind it on the page. In clear renditions it is used only for glass sampling and dimming; the exported icon remains transparent outside its authored glass/content coverage.
The composition is full-bleed: the surface and layers cover the whole square canvas, and the continuous-curvature squircle mask (nominal corner radius 25.8% of the icon size, matching the iOS 26 grid) is applied by a final icon-edge glass pass that refracts and highlights the composition's rim. Exports are transparent outside the mask and contain no baked outer drop shadow — apply page shadows in CSS if desired.
Although layer authoring uses SVG, the final visible output is a canvas/raster image. This keeps glass compositing and deterministic PNG export consistent across arbitrary SVG sources.
SSR
Package imports and server rendering do not access window, document, Image, canvas, ResizeObserver, or WebGL. IDs come from Vue's SSR-stable useId(), so a filter or clip path the server printed is the one the client hydrates onto.
Icons begin rendering after client mount; the server output contains the wrapper, a placeholder canvas, and the hidden SVG source tree. Surfaces render their real markup from the first byte — a squircle's clipped surface layer is there, falling back to a plain border-radius until layout has a box to measure, and its shadow and stroke layers, which have nothing to draw without one, are simply absent.
Browser support
- Modern browsers with Canvas 2D, SVG serialization,
MutationObserver, and Blob URLs. - WebGL2 is optional but recommended for icons; Canvas 2D is the fallback.
createImageBitmapis preferred when available; anHTMLImageElementdecoder is used otherwise.Squircleworks everywhereclip-pathdoes. Only the refractiveLiquidGlasslayer is Chromium-only — see Browser support under surfaces.
Development
npm install
npm run dev # Storybook on :1009
npm run verify
npm run dev opens the Storybook, which is both the component documentation and the fixture surface the Chromium suite drives — stories under Surfaces, Icons and Shapes, plus an Internal section holding the two fixtures that exist only for the tests.
Because the specs navigate to story iframes rather than to a page written for them, a demo and the test that guards it cannot drift. Surfaces → LiquidGlass → Bezel follows the clip renders the same squircle twice — once with the bezel built from its outline, once from a rounded rectangle of the same radius — and tests/browser/surface.spec.ts reads both displacement maps back out of it to check the bezel lands inside the clip.
npm run verify runs strict Vue/TypeScript checking, unit and SSR tests, the Chromium suite, the library and Storybook builds, and the packed-package consumer build.
Related Packages
Liquid Glass for the whole web in one package: core engine plus /react, /vue, /s...
Framework-agnostic iOS 26 Liquid Glass effect Web Components for Vue, Nuxt, Reac...
A component for Vue 3. Anything inside the tag sits on real refracting glass.
A beautiful Vue 3 component that creates a stunning liquid glass morphism effect...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 5.1.0 | >=3.5.0 <4 | Proprietary | |
| 5.0.0 | >=3.5.0 <4 | Proprietary | |
| 4.3.0 | >=3.5.0 <4 | Proprietary | |
| 4.2.0 | >=3.5.0 <4 | Proprietary | |
| 4.1.0 | >=3.5.0 <4 | Proprietary | |
| 4.0.0 | >=3.5.0 <4 | Proprietary | |
| 3.2.0 | >=3.5.0 <4 | Proprietary |