# 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](https://liquid-glass.coline.space) ·
[Source](https://upstream.codes/coline/liquid-glass) ·
[npm](https://www.npmjs.com/package/@colineapp/liquid-glass)
```sh
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
```tsx
import { LiquidGlass } from "@colineapp/liquid-glass/react";
import "@colineapp/liquid-glass/styles.css";
export function Toolbar() {
return (
);
}
```
## Any element
```ts
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 `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.
- 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 `` 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 `glint`s; 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.
```tsx
```
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.
```tsx
const glass = useRef(null);
const button = useRef(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);
}
…
;
```
- `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.
- Pass a point (`{ 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.
- 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.
```tsx
import { LiquidGlassTabLabel, useLiquidGlassTabs } from "@colineapp/liquid-glass/react";
export function Period({ value, onChange }) {
const track = useRef(null);
useLiquidGlassTabs(track);
return (
{["Day", "Week", "Month"].map((label) => (
))}
);
}
```
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-checked` or Radix's `data-state="active"`; pass `isSelected` for
anything else.
- A tab the thumb is dragged onto gets pressed and clicked, as a pointer would.
Pass `onSelect` to handle it yourself.
- The selected label is semibold, and so is the one a drag would land on.
`LiquidGlassTabLabel` holds each label at its semibold width, so tabs don't
change width; without React, mark a label up as
`Week`.
- 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: 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.
## 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.
```tsx
const glass = useRef(null);
useLayoutEffect(() => {
if (open) void glass.current?.materialize({ scale: 0.965 });
}, [open]);
;
```
For CSS-driven animations, run the `liquid-glass-materialize` and
`liquid-glass-dematerialize` keyframes on `.liquid-glass-layer`.
## Accessibility
- `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.
## 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-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.
- **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](https://upstream.codes/coline/liquid-glass)
on upstream.codes:
```sh
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.
## License
MIT