@colineapp/liquid-glass
v0.3.0
Published
Liquid Glass for the web: physically based rim refraction, lighting and materials after iOS 27, on any element.
Maintainers
Readme
liquid-glass
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.
React
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>
);
}Any element
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.
The surface
Put the glass first inside a positioned, rounded element. It takes the element's corner radius, follows its size, and sits behind its content.
- Paint nothing over it: no background, border or shadow on the surface.
- No
opacitybelow 1,filter,maskorbackdrop-filteron 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. - Animate a surface's position and size, not its scale. A changing scale leaves Chromium's backdrop blank for a few frames.
Options
| 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 glass
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.
Menus that pour out of their button
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: truemoves 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.- Pass a point (
{ x, y }, client pixels) for a context menu, andsubmenu: truefor a sub-menu, which unfolds out of its own edge beside its row and folds back faster. sidesays which side of the source the surface sits on; it is measured when left out.- The menu's corners pass through every radius from 4 to 48px. Floating glass
builds them ahead of time when it first appears; if your menus mount only as
they open, call
prewarmLiquidGlass()at startup so the first one pours out sharp.
Tabs
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().
- Your tabs and selection stay yours. The thumb follows
aria-selected,aria-checkedor Radix'sdata-state="active"; passisSelectedfor anything else. - A tab the thumb is dragged onto gets pressed and clicked, as a pointer would.
Pass
onSelectto handle it yourself. - The selected label is semibold, and so is the one a drag would land on.
LiquidGlassTabLabelholds 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>. - Give every label the same, primary text colour: the thumb alone marks the selection, as on iOS 27, and muted labels turn muddy over coloured glass.
- On another glass surface the track gets no glass of its own: it becomes a
recessed tint.
glass: trueorfalsedecides it yourself. --liquid-glass-tabs-tintsets the track's wash, and--liquid-glass-tabs-fillthe thumb's colour.orientation: "vertical"stacks the tabs. Vertical tabs don't scrub.
Appearing and leaving
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.
Accessibility
prefers-reduced-transparencyreplaces the glass with a solid fill of the tint colour.prefers-contrast: moredarkens the sides and turns the edge into a contrasting border.prefers-reduced-motionkeeps 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-colorshides the glass and outlines the selected tab.
How it works
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.
Known gaps
This is a close approximation of Apple's glass, not a copy. Pull requests are welcome for any of these:
- Separate glass doesn't merge. Apple's glass merges and splits as surfaces move near each other. Here glass swells when pressed and leans when dragged, the tab thumb stretches as it travels, and a menu pours out of its button as one drop, but two surfaces never flow into each other.
- Light doesn't follow the device. The rim's glints are lit from a fixed top-left light. They don't move with the pointer or device tilt.
- Refraction is Chromium only. Safari and Firefox don't run SVG filters in
backdrop-filter, so they get the material and rim light without the bend. - Only the rim bends. The flat middle of the surface shows the page unmagnified, and there is no chromatic dispersion at the edge.
- One radius per rounded box. The lens takes
border-top-left-radiusfor every corner, so a box with mixed corner radii gets the wrong rim. Draw it as ashapeinstead, at the cost of a build per size. - Scaling the surface blanks it in Chromium for a few frames, so menus and dialogs can't use a scale-in animation on the surface itself (see Appearing and leaving).
Demo
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.
Source
The repository is coline/liquid-glass on upstream.codes:
git clone https://git.coline.app/coline/liquid-glass.gitThe 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.
License
MIT
