# 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; `"clear"` only bends and lights it; `"lens"` is the clear lens a control lifts into (pair with `LENS_GEOMETRY`) |
| `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, and a press blooms out from there |
| `swell` | `false` | For glass you press, like a search field: it swells about its centre and springs back on release, on the tabs' spring |
| `tone` | | Per-appearance overrides of the variant's filters: `{ dark: { brightness: 0.4 }, light: { tint: 0.5 } }` |
| `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 |
| `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` | `1` | Refraction samples per pixel. `4` antialiases the bend, so labels it stretches don't stair-step; it costs four passes, so keep it for small glass like a lens |
`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
```
## Tabs
The track is a glass capsule and the selected tab a well in it. The motion is
iOS 27's tab bar, measured from UIKit recordings. Choosing a tab, one thumb
lifts off the track into a clear lens that magnifies the labels beneath it,
stretches along its path with its speed, and squashes as it drops back in. The
bar swells while pressed, and the press lights the glass under it. 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.
```tsx
import { 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.
- On another glass surface the track gets no glass of its own: it becomes a
recessed tint. `glass: true` or `false` decides it yourself.
- `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 menu can open and close without fading
its surface. Wells on it ease back to clear glass as it leaves.
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({ duration: 240, scale: 0.96 });
}, [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.
- `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. The result becomes an
`feDisplacementMap` in the surface's `backdrop-filter`, plus four masks: 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.
The tab thumb's four edges each ride their own spring. Travelling, the leading
edge runs on a stiffer one than the trailing edge, so the lens stretches along
its path, then settles critically damped, since any overshoot would leave the
glass wobbling by sub-pixels.
`createGlassSlices()` exposes 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:
- **Glass doesn't squish or merge.** Apple's glass stretches when pressed and
merges and splits as surfaces move near each other. Here the tab thumb
stretches as it travels, interactive glass lights up under the pointer, and
surfaces materialize, but nothing deforms on press or merges.
- **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 surface.** The lens takes `border-top-left-radius` for every
corner, so surfaces with mixed corner radii or non-rectangular shapes get the
wrong rim.
- **Light regular glass can look solid.** Over pale pages its body lifts close
to white. The tones were tuned by eye against iOS screenshots; measured values
would do better.
- **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/0.7` 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