Documentation. Install it, put a class on something, then tune it — every class, variable and export, with live glass along the way. Want to play first? Try the playground.

Three lines to glass. Install the package, import the stylesheet once, and put lb on anything.

  1. Install.

    pnpm add liquid-blur
  2. Import the stylesheet, once, anywhere in your app.

    import "liquid-blur/liquid-blur.css";
  3. Add the class. That's the whole material.

    <button class="lb">Glass</button>
  4. Install the script if you use press behaviors. One delegated listener covers the whole document, so elements mounted later just work — in React, Vue, Svelte, Astro or plain HTML.

    import { installLiquidBlur } from "liquid-blur";
    
    installLiquidBlur();

No build step? Link the stylesheet and load the auto entry, which installs itself.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/liquid-blur@1.0.0/dist/liquid-blur.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/liquid-blur@1.0.0/dist/auto.js"></script>

Seven classes. One for the material, two for its character, three for how it answers a touch, and one for all three. They combine freely.

lbThe material. Backdrop blur, a fill, volume, rims, an edge and a shadow. Required.

lb-clearAt least transparency 1. For glass over photos and video, where the content is the point.

lb-tintedThe fill is --lb-tint and the text is --lb-tint-text. Volume and rims stay.

lb-highlightLights up where it's pressed and follows the finger. Press it.

lb-swellGrows while pressed, on a spring that can turn around mid-flight. Small glass grows more, big glass barely.

lb-stretchPulls toward the finger and stretches along the drag. Small glass is lively, big glass is heavy. Drag it.

lb-interactiveAll three at once, the usual set for a button, chip or icon. Press it, drag it.

Shape is plain border-radius; the default is a capsule. Press behaviors are for single controls, not containers.

One knob per idea. Every variable is inherited, like a SwiftUI environment value: set it on any ancestor, and all glass inside picks it up.

Material
--lb-transparency0.50 is opaque, 1 is clear. Moves fill and blur together.
--lb-depth0.50 is flat, 1 is pronounced. Moves shading, glow and rims.
--lb-darkper theme0 is light, 1 is dark. Follows the system and data-theme.
Color
--lb-fill-light#ffffffFill in the light theme.
--lb-fill-dark#3a3a3aFill in the dark theme.
--lb-fillunsetOne fill for both themes; wins over the two above.
--lb-text-light#000000Text in the light theme.
--lb-text-dark#ffffffText in the dark theme.
--lb-textunsetOne text color for both themes.
--lb-tint#007affColor of lb-tinted glass.
--lb-tint-text#ffffffText on lb-tinted glass.
--lb-shade-color#000000The dark parts: volume shading, edge, drop shadow.
--lb-shine-color#ffffffThe light parts: rims, glow, press highlight.
Behavior
--lb-highlight-size1Reach of the press highlight, as a multiplier. It already follows the element's size.
--lb-swell1.1Pressed scale at button size. Smaller glass grows more, bigger less.
--lb-stretch2Stretch strength; 0 turns it off.
.toolbar {
  --lb-transparency: 0.8;
  --lb-depth: 0.3;
}

.danger {
  --lb-tint: #ff3b30;
}

Everything prefixed --_ is internal calibration and may change without notice.

Your colors, our physics. Colors resolve on the glass itself, so they follow the theme in effect — even on a subtree. You set the color; the material still decides how much of it shows.

/* Your own neutral, per theme */
:root {
  --lb-fill-light: #f5f5f7;
  --lb-fill-dark: #1c1c1e;
}

/* Warm glass: fill, shading and rims all lean amber */
.warm {
  --lb-fill-light: #ffe08a;
  --lb-shade-color: #5a2a00;
  --lb-shine-color: #fff4c0;
}

/* A light tint needs dark text */
.warning {
  --lb-tint: #ffcc00;
  --lb-tint-text: #111;
}

The glass sets its own text color for contrast with its fill. A color on the element still wins, as does --lb-text.

Light, dark, and yours. The theme is one inherited number, --lb-dark. It follows the system by default, anddata-theme on any element switches its subtree.

<html data-theme="dark">
  <section data-theme="light">
    <button class="lb">Always light</button>
  </section>
</html>

Any other theme switch plugs in with a single line:

/* Tailwind, shadcn, next-themes with attribute="class" */
.dark { --lb-dark: 1; }

/* daisyUI and other named themes */
[data-theme="dracula"] { --lb-dark: 1; }

Plays well with Tailwind. All styles live in@layer liquid-blur, so any unlayered rule of yours wins without!important. Between layers the first one declared loses — declare ours first, and utilities like rounded-xl win.

@layer liquid-blur, theme, base, components, utilities;
@import "tailwindcss";
@import "liquid-blur/liquid-blur.css";

Glass that steps aside. Transparency is a choice, not a requirement. Every system setting that asks for less of it is honored without a line of your code.

  • Reduced transparency. With the system setting on, glass turns opaque and the backdrop filter goes away.

  • Increased contrast. Opaque glass with a stronger edge, so every control keeps its outline.

  • Forced colors. Windows High Contrast drops backgrounds and shadows; an outline takes their place, one around a melted group.

  • Reduced motion. No swell, no stretch; morphs switch immediately. Presses still light up.

  • Keyboard. Enter and Space on a focused glass press it, until that same key comes up.

  • Solid material. data-lb-material="solid" on any ancestor makes all glass inside it opaque.

Made for fingers. Presses feel like iOS, on any device.

Pressed stateWhile a pointer is down the element gets data-lb-pressed, which you can style too. On touch it waits a beat, so a finger landing to scroll doesn't flash it.

Springs, not transitionsHighlight and swell run on interruptible springs and clean up after themselves. Small glass swells more and bouncier, big glass barely. Your owntransition stays yours; the swell multiplies your scale.

StretchLike the swell, it has mass: the bigger the glass, the less it gives and bounces. Writes onlytransform, once a frame, with no layout reads, and restores yours when it settles.

Release outsideLet go more than 32px outside the element and the click is cancelled, as on iOS.

Glass that melts. Pieces of one group share a single surface that flows between them when they come close, like drops of liquid. One backdrop, clipped to the merged outline, so nothing blurs twice. More in the lab.

Split and dockPress the search button. It moves with a plain CSS transition, and the glass follows it out and back, neck and all.

Pull itPress and drag any piece. Stretch and swell bend the outline between them as they go.

DropsA CSS @keyframes animation, nothing else: the drop pulls away from the pill and falls back in. With reduced motion it stays put.

Liquid Blur
<div class="lb-group">
  <div class="lb">…</div>
  <button class="lb lb-interactive">…</button>
</div>

<script type="module">
  import { installGlassGroups } from "liquid-blur/group";

  installGlassGroups();
</script>

Pay only when you use itGroups live in their own entry, liquid-blur/group. Apart, the children are plain glass and the group draws nothing; without the script they stay plain glass too.

Merge distanceChildren start to melt --lb-merge apart, 24px by default. Set it on the group.

Follows on its ownSprings, stretch and swell, inline styles and classes, size changes, CSS transitions and animations of the children. Anything else moving them: call update().

Same materialTheme, transparency, depth and colors come from the same variables as any glass. The root gets data-lb-melted while it draws for the children.

installGlassGroups(root?: Document): () => void
Turns every .lb-group into a group, including ones mounted later, and lets go of those that leave the page. For an element you manage yourself, there'screateGlassGroup():

import { createGlassGroup } from "liquid-blur/group";

const group = createGlassGroup(toolbar);

// A child moved by something the group can't see
group.update();

// Later
group.destroy();

Server rendering. The entry is safe to import on the server, andinstallGlassGroups() does nothing there. Rendered without the script, the children are plain glass; close ones melt once it runs. With React, Vue or Svelte, install after hydration (in an effect), since the group adds its own elements to the root, and writelb lb-group in the markup, so a re-render that sets the class keeps lb.

import { useEffect } from "react";
import { installGlassGroups } from "liquid-blur/group";

export function Toolbar() {
  // After hydration: the group adds its own elements to the root
  useEffect(() => installGlassGroups(), []);
  return (
    // "lb" in the markup too, so a re-render keeps it
    <div className="lb lb-group">
      <div className="lb">…</div>
      <button className="lb lb-interactive">…</button>
    </div>
  );
}

A control becomes a panel. The same glass grows, moves and changes its corners, then shrinks back. Content arrives magnified and blurred, and settles sharp. Try the different layouts in the lab.

Lay out the content at its open position and size. Hide it with visibility: hidden, so it stays measurable. The source supplies the glass; the content does not need lb.

<div class="morph-scene">
  <button id="more" class="lb" type="button" aria-controls="panel">More</button>
  <div id="panel">
    <p>Panel content</p>
    <button id="close-panel" type="button">Close</button>
  </div>
</div>

<style>
  .morph-scene { position: relative; min-height: 240px; }
  #more { width: 88px; height: 44px; border-radius: 22px; }
  #panel {
    position: absolute;
    left: 0;
    top: 0;
    width: min(280px, 100%);
    height: 220px;
    box-sizing: border-box;
    padding: 20px;
    border-radius: 28px;
    visibility: hidden;
    z-index: 2;
  }
</style>
import "liquid-blur/liquid-blur.css";
import { createMorph } from "liquid-blur/morph";

// After mounting or hydration
const source = document.getElementById("more")!;
const content = document.getElementById("panel")!;
const close = document.getElementById("close-panel")!;

const morph = createMorph({
  source,
  content,
  onRest(open) {
    (open ? close : source).focus({ preventScroll: true });
  },
});

const toggle = () => morph.toggle();
const dismiss = () => morph.close();
source.addEventListener("click", toggle);
close.addEventListener("click", dismiss);

// On unmount:
// source.removeEventListener("click", toggle);
// close.removeEventListener("click", dismiss);
// morph.destroy();

State and completionopen(), close() and toggle() can reverse a running animation. isOpen is the requested state; onRest(open) reports when the movement finishes. destroy() removes the copies and restores the content.

Springs in each directionPass spring: { open, close }, each with x, y,width, height and progress settings.defaultMorphSprings is exported from liquid-blur/morph.

Keep the content above the glass and leave its transform, opacity,filter and clip-path to the morph. Geometry is measured on open and close; scrolling or resizing while open is not tracked. Rotated or scaled ancestors are unsupported. Corner shapes interpolate where corner-shape is supported; otherwise corners stay round.

The source's aria-expanded is managed automatically. Focus, Escape, outside clicks and menu or dialog keyboard behavior belong to your app. With reduced motion, the panel and control swap immediately. Create the morph after mounting or hydration and destroy it on unmount.

Presses and springs. The main entry turns presses on and exports the spring that powers them. Groups and morphs use their own entries above.

installLiquidBlur(root?: Document): () => void
Installs press behaviors on a document and returns a cleanup. Installing twice returns the first cleanup. Looks through open shadow roots, works in iframes, and does nothing during server rendering.

const uninstall = installLiquidBlur();

// An iframe has its own document
installLiquidBlur(frame.contentDocument);

// Later, if the page goes away
uninstall();

SpringAnimator
Animates several numbers at once with SwiftUI-style springs: a perceptual duration in seconds and a bounce from 0 to 1. Retargeting keeps the velocity, so reversing mid-flight stays smooth — which CSS transitions can't do.

import { SpringAnimator } from "liquid-blur";

const spring = new SpringAnimator(
  { width: 100, radius: 12 },
  { duration: 0.4, bounce: 0.2 },
  ({ width, radius }) => {
    el.style.width = `${width}px`;
    el.style.borderRadius = `${radius}px`;
  },
);

spring.to({ width: 240, radius: 24 });

Each channel can have a spring of its own:

new SpringAnimator(
  { opacity: 0, scale: 1 },
  {
    opacity: { duration: 0.15, bounce: 0 },
    scale: { duration: 0.45, bounce: 0.3 },
  },
  ({ opacity, scale }) => { /* … */ },
);

SpringParams contains duration, bounce and optionalsettle. Duration is positive and measured in seconds; it is not a fixed completion deadline. Use bounce below 1 for a spring that settles. Settle changes bounce after the first reversal and defaults to the original bounce.

Browser support. Anything withbackdrop-filter, color-mix() and CSS pow(). Browsers without @property use fallbacks. Groups also needclip-path: path() and Canvas 2D. JavaScript is ESM targeting ES2022.

Questions? Answers.

Why no refraction?
Refraction on the web means SVG filters, which only one engine applies to a backdrop, and which cost a lot for every frame of scrolling. Liquid Blur keeps to what renders the same everywhere — so the glass you design is the glass everyone sees.
Does it need JavaScript?
No. The material is pure CSS. Optional scripts add press behaviors, glass groups and morphs. Without them, highlight and swell fall back to :active.
How do I use it with React, Vue or Svelte?
Use the classes as they are, and install the script once at the root. It returns its own cleanup.
import { useEffect } from "react";
import { installLiquidBlur } from "liquid-blur";

export function App() {
  useEffect(() => installLiquidBlur(), []);
  return <button className="lb lb-highlight">Glass</button>;
}
What does it cost?
A backdrop filter is work for the GPU, proportional to the area of glass on screen — so glass the size of a toolbar is cheap, and glass the size of the page is not. The script writes only transforms and two private properties per frame, and never reads layout while animating.
Is this Apple's Liquid Glass?
No. It's inspired by it and doesn't copy it: a calmer material, with no lensing, built for the web as it is.