Liquid Blur

Glass for the web. One class to make it, two numbers to tune it.

Now Playing

Surface Tension — Lumen. Backdrop blur, a fill, rims and a little light.

0.50
0.50
Material
Tint
Press
Group
Theme
Backdrop

On this page

Get started

Install the package, import the stylesheet once, and put lb on anything.

  1. Install.

    pnpm add @fluidkit/blur
  2. Import the stylesheet, once, anywhere in your app.

    import "@fluidkit/blur/style.css";
  3. Add the class.

    <button class="lb">Glass</button>
  4. Install press if you use the press classes (lb-highlight, lb-swell, lb-stretch, lb-interactive). It adds one listener to the document, so elements added later work too, with any framework. On the server it does nothing.

    import { installPress } from "@fluidkit/blur/press";
    
    installPress();

No build step? Link the stylesheet and load the auto entry, which installs press itself. With a bundler, import "@fluidkit/blur/auto" does the same.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fluidkit/blur@1/dist/style.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@fluidkit/blur@1/dist/auto.js/+esm"></script>

Entries

The material is plain CSS. Press and melt are optional scripts, each in its own entry: import only what you use. They work independently of each other.

Material @fluidkit/blur/style.css

The glass: lb, its variants, and the styles press and melt rely on.

Press @fluidkit/blur/press

Exports installPress. Runs highlight, swell and stretch on springs.

Melt @fluidkit/blur/melt

Exports installGlassGroups, createGlassGroup and the type GlassGroup. Merges nearby glass into one surface; needs the stylesheet.

Auto @fluidkit/blur/auto

Installs press on import. No named exports. For pages without a build step. Glass groups aren’t installed: load melt too and call installGlassGroups().

To flow a control into a panel of the same glass, use Morph. It works inside glass groups.

Classes

lb is the material. lb-clear and lb-tinted change its look. lb-highlight, lb-swell and lb-stretch react to presses, and lb-interactive turns on all three. Combine them 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.

Set the shape with border-radius; the default is a capsule.

<button class="lb lb-interactive">Press me</button>
<div class="lb lb-highlight lb-stretch">A bar: lights and stretches, doesn't swell</div>
<button class="lb lb-tinted lb-highlight">Tinted</button>
<div class="lb lb-clear">Over a photo</div>
<div class="lb" style="border-radius: 24px; padding: 16px">Card</div>

Layers

The glass is built from these layers, back to front. It uses only features that every browser engine renders the same way, and no pseudo-elements, so ::before and ::after stay free for you.

  1. Backdrop: blur and saturate.
  2. Fill: one color, or the tint, at a computed opacity.
  3. Volume: the surface darkens toward its edges on a smooth curve and catches a little light just under the top and bottom rims.
  4. Rims: light hairlines along the top and bottom; inset shadows, so on a capsule they fade toward the ends.
  5. Edge: a dark hairline just outside.
  6. Drop shadow.

Customize

All variables inherit: set one on any ancestor and every glass element inside uses it.

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.

Colors

Colors are resolved on each glass element, so they follow whichever theme applies there. Transparency and depth still control how strongly a color 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;
}

/* Same fill in both themes */
.brand {
  --lb-fill: #0a3;
}

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

Glass picks a text color that contrasts with its fill. To override it, set color on the element, or --lb-text.

Theming

The theme is one inherited variable, --lb-dark: 0 is light, 1 is dark. By default it follows the system setting. data-theme="light" or "dark" on any element switches everything inside it.

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

To connect another theme switcher, set --lb-dark where it marks the dark theme:

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

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

Tailwind & layers

All styles are in @layer liquid-blur, so your unlayered CSS overrides them without !important. Among layers, the one declared first has the lowest priority. Tailwind v4 declares its layers when imported, so glass imported after it would override utilities such as rounded-xl. Declare the layer order once, before both imports:

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

Accessibility

Glass follows the system’s accessibility settings on its own; no code needed.

  • 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.

Press behaviors

The press classes need installPress() (see Get started). Without it, highlight and swell fall back to :active, centered on the element.

Pressed state

While pressed, the element has data-lb-pressed, which you can style. On touch it appears after a short delay, so starting a scroll doesn’t trigger it; a quick tap still flashes briefly on release.

Highlight

A soft glow under the finger. Its size grows with the element, up to a limit on large panels. --lb-highlight-size multiplies it.

Springs

Highlight and swell run on springs that can reverse mid-way. While they move, the script sets scale, the highlight strength and the press point (--lb-press-x, --lb-press-y) inline, and removes them at rest. Swell multiplies your own scale.

Size

Larger glass swells and stretches less, moves slower and bounces less. --lb-swell and --lb-stretch adjust the strength.

Stretch

Writes only transform, once per frame, without reading layout. Your own transform is kept and the stretch is added after it; your inline transform and will-change are restored at rest. Stretch sets touch-action: none and disables text selection on the element.

Release outside

Releasing more than 32px outside the element cancels the click.

Don’t put a CSS transition on scale for swell elements or on transform for stretch elements: the script updates them every frame and a transition would lag behind. Use press classes on single controls, not containers: highlight lights the whole element on a press anywhere inside it, and stretch blocks scrolling and text selection in its whole subtree.

A disabled control (:disabled, or aria-disabled="true") isn’t pressed: no highlight, swell or stretch, with the script or without. How it looks disabled is up to you.

Glass groups

Glass elements inside an lb-group merge into one surface when they get close and separate as they move apart. The group draws one backdrop clipped to the merged outline, so overlaps aren’t blurred twice.

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
<div class="lb lb-group">
  <div class="lb">…</div>
  <button class="lb lb-interactive">…</button>
</div>

<script type="module">
  import { installGlassGroups } from "@fluidkit/blur/melt";

  installGlassGroups();
</script>
Optional script

Groups need the @fluidkit/blur/melt entry. Without it, or while the children are far apart, they render as ordinary glass.

Merge distance

Children start to merge when they are closer than --lb-merge, 18px by default. Set it on the group.

Tracking movement

The group follows its children’s springs, inline style and class changes, size changes, and CSS transitions and animations. For anything else that moves them, call update().

Same material

The group uses the same variables as any glass. While it draws the shared surface, the root has data-lb-melted. An lb-tinted child keeps its tint on top.

Server rendering. The entry can be imported on the server; installGlassGroups() does nothing there. In React, Vue or Svelte, call it after hydration (in an effect), because the group adds its own elements to the root. Write both lb lb-group in the markup, so a re-render that sets the class doesn’t drop lb.

import { useEffect } from "react";
import { installGlassGroups } from "@fluidkit/blur/melt";

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>
  );
}

API

The functions behind press and melt. What each entry exports is listed under Entries.

installPress(root?: Document): () => void
Installs press behaviors on root (default document) and returns a function that uninstalls them. Calling it again returns the same function. While installed, <html> has data-lb-interaction. Works inside open shadow roots; for an iframe, pass its contentDocument. Does nothing without a DOM.

const uninstall = installPress();

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

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

installGlassGroups(root?: Document): () => void
Turns every .lb-group in the document into a group, including ones added later, and releases a group when its root leaves the document. Returns a cleanup that destroys the groups it created; calling it again returns the same cleanup. Does nothing without a DOM.

createGlassGroup(root: HTMLElement): GlassGroup
Creates a group for one element you manage yourself. Adds lb and lb-group to root if missing. Calling it again for the same root returns the existing group.

import { createGlassGroup } from "@fluidkit/blur/melt";

const group = createGlassGroup(toolbar);

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

// Later
group.destroy();

Browser support

Any browser with backdrop-filter, color-mix() and CSS pow(). Without @property (Firefox before 128) the glass still renders, but some settings inherit into nested glass and the press highlight doesn’t fade. Groups also need clip-path: path(), mask with an inline SVG, and Canvas 2D. The JavaScript is ESM targeting ES2022.

Limitations

  • Backdrop roots. Glass blurs only what’s inside its backdrop root. An ancestor with filter, opacity below 1, mask, clip-path, mix-blend-mode or its own backdrop-filter cuts the glass off from everything behind that ancestor.
  • Transforms on groups. Scaling a group or its ancestors works; rotating doesn’t. Movement the group can’t see needs update(). The merged outline uses circular arcs, even for squircle children.
  • Clipping around a group. In Chromium, an ancestor with overflow: hidden (or clip, or paint containment) and a corner-shape other than round shows a blurred rectangle around merged glass. Put the shape on the ancestor’s background instead and leave it unclipped. Clipping with plain border-radius is fine.
  • No refraction. See the FAQ.

FAQ

Why no refraction?

Refraction on the web needs SVG filters, which only one browser engine applies to a backdrop, and they are expensive on every frame of scrolling. Liquid Blur uses only what renders the same in every browser.

Does it need JavaScript?

No. The material is plain CSS. The optional scripts add press behaviors and glass groups.

How do I use it with React, Vue or Svelte?

Use the classes as they are, and install press once at the root. It returns its own cleanup.

import { useEffect } from "react";
import { installPress } from "@fluidkit/blur/press";

export function App() {
  useEffect(() => installPress(), []);
  return <button className="lb lb-highlight">Glass</button>;
}
How fast is it?

A backdrop filter costs GPU time in proportion to the area of glass on screen: a toolbar is cheap, a full-page panel is not. While animating, the script writes only transforms and two custom properties per frame and never reads layout.

Is this Apple's Liquid Glass?

No. It’s inspired by it, but has no lensing or refraction and uses only what browsers support today.