Liquid Glass for the web: the rim refraction, light and motion of iOS 27 glass on any rounded element. React bindings and pill tabs. MIT.
Liquid Glass for the web. Rim refraction, lighting and materials after iOS 27, computed from a lens profile instead of drawn by hand. One function for any element, a component for React, and tabs whose thumb lifts into a lens.
Playground and docs · Source · npm
npm install @colineapp/liquid-glass
| Browser | What you get |
|---|---|
| Chrome, Edge, Arc, Opera and other Chromium browsers | Full glass with rim refraction |
| Safari, Firefox | The same material and rim light, without the bend |
Refraction needs SVG filters inside backdrop-filter, which only Chromium
renders. Elsewhere the library leaves the filter out rather than lose the
backdrop.
import { LiquidGlass } from "@colineapp/liquid-glass/react";
import "@colineapp/liquid-glass/styles.css";
export function Toolbar() {
return (
<nav className="relative rounded-full px-6 py-3">
<LiquidGlass appearance="class" />
…
</nav>
);
}
import { createLiquidGlass } from "@colineapp/liquid-glass";
import "@colineapp/liquid-glass/styles.css";
const glass = createLiquidGlass(document.querySelector(".toolbar")!, { variant: "clear" });
glass.update({ variant: "regular" });
glass.destroy();
attachLiquidGlass(element, options) builds the glass inside an element you
render yourself, the way the React component does.
Put the glass first inside a positioned, rounded element. It takes the element's corner radius, follows its size, and sits behind its content.
opacity below 1, filter, mask or backdrop-filter on the surface or
its ancestors while the glass shows. Each of those makes a backdrop root, and
the glass would only see that element instead of the page.| Option |
|---|
| Default |
|---|
appearance | "system" | "dark", "light", "system" (prefers-color-scheme) or "class" (dark while <html> has the dark class) |
variant | "regular" | "regular" diffuses the page so text stays legible, as clear or tinted as its level; "clear" is regular glass at level 0, the page barely touched but for the rim; "lens" is the clear lens a control lifts into (pair with LENS_GEOMETRY) |
level | 0.5 | How clear or tinted regular glass is, as iOS 27's Liquid Glass slider sets it: 0 More Clear, 0.5 a light frost and a faint wash, 1 More Tinted (iOS 27's own default), anything in between. The rim doesn't change with it |
floating | false | For menus, popovers and dialogs: diffuses the page more, so text beneath doesn't read through, and the surface casts a shadow (--liquid-glass-shadow). "dialog" casts a dialog's deeper one (--liquid-glass-dialog-shadow) |
interactive | false | For glass that holds controls: the pointer warms the glass beneath it, a press blooms out from there while the glass swells, and light glass floats on a faint shadow (--liquid-glass-control-shadow) |
swell | as interactive | Pressed, the surface swells about its centre and springs back with a slight overshoot; held and dragged, it leans after the pointer like taffy. For glass you press, like a search field |
tone | Per-appearance overrides of the variant's filters: { dark: { brightness: 0.8 }, light: { tint: 0.3 } } | |
geometry | measured from iOS 27 | Rim width, depth and refractive index; the darkened edge and how much of the hairline gathers into glints; hairline, side shade and glow widths; maxFall, how steeply the rim may bend; rimShare, how much of the corner radius the top and bottom rims may take |
shape | Glass of any outline: an SVG path (absolute M L H V C Z) in the surface's pixels, or a function from its size to one. Rendered whole per size, so keep it to surfaces that don't resize. Its rims stay within the surface's border-radius | |
tint | near-black / white | Colour washed over the body by the tone's tint share. Any CSS colour, including var() |
hue | none | A colour whose hue the glass takes on. Neutral colours leave it untinted |
hueStrength | 1 light, 0.5 dark | How much of the hue it takes |
fit | "box" | "box" fills the surface, "fixed" fills a transformed popper wrapper, "scroll" spans a surface that scrolls itself |
refraction | true | Bend the page at the rim where the browser supports it |
samples | 4 | Refraction samples per pixel. Four antialias the bend, so what it stretches doesn't stair-step; 1 is cheaper for large glass |
glass.magnify(factor) magnifies what is beneath the glass about its centre, as
a raised lens does (1 for none). It is cheap enough to call every frame, and
sharpens what it magnifies so it doesn't come out soft.
Controls on a glass surface shouldn't get glass of their own. Give the selected
or hovered one the liquid-glass-well class and it brightens the glass beneath
it.
<button className={selected ? "liquid-glass-well rounded-full" : "rounded-full"}>Today</button>
For the rows of a menu or list, attachLiquidGlassHighlight(surface) (or
useLiquidGlassHighlight(ref)) adds a well that slides to the row under the
pointer or with focus. Each edge rides its own spring, so it stretches toward
the row it is heading for and gathers up as it lands. It finds menu items and
options by default; pass items for other rows.
On iOS 27 a menu is its button, grown: the button becomes a drop of glass that
inflates into the menu over it, and closing, the menu shrinks back into the
button as a drop, which bulges as it takes it back. pourFrom() and
drawBack() do that with the surface's own glass, so it is the same glass on
every frame, and carry the contents inside it.
const glass = useRef<LiquidGlassHandle | null>(null);
const button = useRef<HTMLButtonElement>(null);
// Once the menu is shown and placed:
useLayoutEffect(() => {
if (open && button.current) void glass.current?.pourFrom(button.current, { cover: true });
}, [open]);
async function close() {
// false if it was reopened while drawing back.
if (await glass.current?.drawBack()) setShown(false);
}
<div role="menu" className="absolute rounded-3xl p-1.5">
<LiquidGlass glassRef={glass} floating />
…
</div>;
cover: true moves the menu over its button, as iOS 27's menus sit, for
buttons up to 56px tall. Leave it off for a popover anchored to a field it
mustn't hide; it still grows out of the field.{ x, y }, client pixels) for a context menu, and
submenu: true for a sub-menu, which unfolds out of its own edge beside its
row and folds back faster.side says which side of the source the surface sits on; it is measured
when left out.prewarmLiquidGlass() at startup so the first one pours out
sharp.A horizontal track is iOS 27's segmented control, measured frame by frame from SwiftUI recordings. The selected tab is a solid thumb, white in light and grey in dark. Choosing a tab, it clears from the centre out into a clear lens larger than the track, over the labels, which bend at its rim; it stretches along its path with its speed, hangs at the tab it reached for a moment, and drops back in, refilling from the rim. Pressing the selected tab lifts it in place, and dragging it scrubs across the others. A change of tab the viewer didn't make, such as a route change, slides flat. A vertical list is a sidebar instead: its selected tab is a well in the glass, and its lens grows less.
import { LiquidGlassTabLabel, useLiquidGlassTabs } from "@colineapp/liquid-glass/react";
export function Period({ value, onChange }) {
const track = useRef(null);
useLiquidGlassTabs(track);
return (
<div ref={track} role="tablist" className="flex rounded-full p-0.5">
{["Day", "Week", "Month"].map((label) => (
<button key={label} role="tab" aria-selected={label === value} onClick={() => onChange(label)}>
<LiquidGlassTabLabel>{label}</LiquidGlassTabLabel>
</button>
))}
</div>
);
}
Without React, attachLiquidGlassTabs(track, options) does the same and
returns update() and destroy().
aria-selected,
aria-checked or Radix's data-state="active"; pass isSelected for
anything else.onSelect to handle it yourself.LiquidGlassTabLabel holds each label at its semibold width, so tabs don't
change width; without React, mark a label up as
<span class="liquid-glass-tabs-label" data-text="Week"><span>Week</span></span>.glass: true or false decides it yourself.--liquid-glass-tabs-tint sets the track's wash, and
--liquid-glass-tabs-fill the thumb's colour.orientation: "vertical" stacks the tabs. Vertical tabs don't scrub.materialize() grows the glass from clear to its tone, and dematerialize()
fades it back and holds it there, so a dialog or sheet can open and close
without fading its surface. Wells on it ease back to clear glass as it leaves.
Both ease out on a cubic curve, 240ms in and 180ms out by default: on a
steeper curve half of an opening lands in its first frame, and the glass seems
to pop.
For a surface that zooms as it opens, scale its contents and pass the same
scale: the glass grows by its inset instead, since scaling it blanks it.
const glass = useRef<LiquidGlassHandle | null>(null);
useLayoutEffect(() => {
if (open) void glass.current?.materialize({ scale: 0.965 });
}, [open]);
<LiquidGlass glassRef={glass} floating="dialog" />;
For CSS-driven animations, run the liquid-glass-materialize and
liquid-glass-dematerialize keyframes on .liquid-glass-layer.
prefers-reduced-transparency replaces the glass with a solid fill of the tint colour.prefers-contrast: more darkens the sides and turns the edge into a contrasting border.prefers-reduced-motion keeps the tab thumb from lifting and travelling, the
glass from swelling, and menus from pouring: a covered button still becomes
its menu, at once.forced-colors hides the glass and outlines the selected tab.The rim is a squircle-shaped bevel. For each pixel across it, a view ray meets
the tilted surface, refracts by Snell's law and travels through the glass to the
page. The shift is capped wherever it would fall faster than maxFall times the
rim moving inward. Past 1, neighbouring pixels would sample in reverse and fold
the page into mirrored echoes; lower caps bend more gently, which the tab lens
uses so labels crossing its rim don't smear. On small glass the top and bottom
rims stop short of the middle (rimShare), so the body shows the page as it is.
The result becomes an feDisplacementMap in a layer of its own over the body,
masked to where the rim bends: the displacement takes the nearest pixel, and run
over the whole body it drew the page coarser than the page itself on some GPUs.
Four more masks draw the rim hairline, darkened sides, inner glow, and the
darkened edge where the glass meets the air. Part of the hairline gathers into
glints where a light from the top-left catches the curve, fainter where it
returns at the bottom-right.
The lens is rendered once per corner radius as a rounded square of side 2r + 1 and cut into nine slices. Fixed corners and stretched edges assemble it over a box of any size, so surfaces can resize and change radius while the glass stays on. Glass of another outline is rendered whole: each pixel is shaded from its distance to the nearest point of the outline and the outline's normal there.
The tab thumb's centre and size ride one spring, as UIKit's do, and its stretch follows its speed a beat behind. A pouring menu's width, height and corner each ride their own: its height leads and overshoots, its width follows, and the corners stay round in flight and tighten as it lands. Every spring steps at a fixed 240 Hz, so motion is the same at any frame rate.
createGlassSlices() and createShapedGlass() expose the lens as raw pixels if
you want to use it somewhere else.
This is a close approximation of Apple's glass, not a copy. Pull requests are welcome for any of these:
backdrop-filter, so they get the material and rim light without the bend.border-top-left-radius for
every corner, so a box with mixed corner radii gets the wrong rim. Draw it as
a shape instead, at the cost of a build per size.The playground and docs are at https://liquid-glass.coline.space,
with plain-text docs for agents at /llms.txt and /agents.md. demo/ is
that site, a Coline Site built on the published package. Run it with
npm install && npm run dev inside that folder.
The repository is coline/liquid-glass on upstream.codes:
git clone https://git.coline.app/coline/liquid-glass.git
The package is developed inside Coline's monorepo, at packages/liquid-glass,
next to the app whose glass it ships, and this repository is published from
there. Issues and change requests are welcome here and get ported back. In the
monorepo, scripts/sync-liquid-glass.sh republishes it. It splits
packages/liquid-glass out of origin/main with git subtree split, which
gives the same commits every time, and pushes the result here as main, so
every sync is a fast-forward.
MIT