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.
| 1 | # liquid-glass |
| 2 | |
| 3 | Liquid Glass for the web. Rim refraction, lighting and materials after iOS 27, |
| 4 | computed from a lens profile instead of drawn by hand. One function for any |
| 5 | element, a component for React, and tabs whose thumb lifts into a lens. |
| 6 | |
| 7 | [Playground and docs](https://liquid-glass.coline.space |
| 8 | [Source](https://upstream.codes/coline/liquid-glass) · |
| 9 | [npm](https://www.npmjs.com/package/@colineapp/liquid-glass) |
| 10 |
| 11 | ```sh |
| 12 | npm install @colineapp/liquid-glass |
| 13 | ``` |
| 14 |
| 15 | | Browser | What you get | |
| 16 | | --- | --- | |
| 17 | | Chrome, Edge, Arc, Opera and other Chromium browsers | Full glass with rim refraction | |
| 18 | | Safari, Firefox | The same material and rim light, without the bend | |
| 19 |
| 20 | Refraction needs SVG filters inside `backdrop-filter`, which only Chromium |
| 21 | renders. Elsewhere the library leaves the filter out rather than lose the |
| 22 | backdrop. |
| 23 |
| 24 | ## React |
| 25 |
| 26 | ```tsx |
| 27 | import { LiquidGlass } from "@colineapp/liquid-glass/react"; |
| 28 | import "@colineapp/liquid-glass/styles.css"; |
| 29 |
| 30 | export function Toolbar() { |
| 31 | return ( |
| 32 | <nav className="relative rounded-full px-6 py-3"> |
| 33 | <LiquidGlass appearance="class" /> |
| 34 | … |
| 35 | </nav> |
| 36 | ); |
| 37 | } |
| 38 | ``` |
| 39 |
| 40 | ## Any element |
| 41 |
| 42 | ```ts |
| 43 | import { createLiquidGlass } from "@colineapp/liquid-glass"; |
| 44 | import "@colineapp/liquid-glass/styles.css"; |
| 45 |
| 46 | const glass = createLiquidGlass(document.querySelector(".toolbar")!, { variant: "clear" }); |
| 47 |
| 48 | glass.update({ variant: "regular" }); |
| 49 | glass.destroy(); |
| 50 | ``` |
| 51 |
| 52 | `attachLiquidGlass(element, options)` builds the glass inside an element you |
| 53 | render yourself, the way the React component does. |
| 54 |
| 55 | ## The surface |
| 56 |
| 57 | Put the glass first inside a positioned, rounded element. It takes the |
| 58 | element's corner radius, follows its size, and sits behind its content. |
| 59 |
| 60 | - Paint nothing over it: no background, border or shadow on the surface. |
| 61 | - No `opacity` below 1, `filter`, `mask` or `backdrop-filter` on the surface or |
| 62 | its ancestors while the glass shows. Each of those makes a backdrop root, and |
| 63 | the glass would only see that element instead of the page. |
| 64 | - Animate a surface's position and size, not its scale. A changing scale leaves |
| 65 | Chromium's backdrop blank for a few frames. |
| 66 |
| 67 | ## Options |
| 68 |
| 69 | | Option | Default | | |
| 70 | | --- | --- | --- | |
| 71 | | `appearance` | `"system"` | `"dark"`, `"light"`, `"system"` (prefers-color-scheme) or `"class"` (dark while `<html>` has the `dark` class) | |
| 72 | | `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`) | |
| 73 | | `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`) | |
| 74 | | `interactive` | `false` | For glass that holds controls: the pointer warms the glass beneath it, and a press blooms out from there | |
| 75 | | `swell` | `false` | For glass you press, like a search field: it swells about its centre and springs back on release, on the tabs' spring | |
| 76 | | `tone` | | Per-appearance overrides of the variant's filters: `{ dark: { brightness: 0.4 }, light: { tint: 0.5 } }` | |
| 77 | | `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 | |
| 78 | | `tint` | near-black / white | Colour washed over the body by the tone's `tint` share. Any CSS colour, including `var()` | |
| 79 | | `hue` | none | A colour whose hue the glass takes on. Neutral colours leave it untinted | |
| 80 | | `hueStrength` | 1 light, 0.5 dark | How much of the hue it takes | |
| 81 | | `fit` | `"box"` | `"box"` fills the surface, `"fixed"` fills a transformed popper wrapper, `"scroll"` spans a surface that scrolls itself | |
| 82 | | `refraction` | `true` | Bend the page at the rim where the browser supports it | |
| 83 | | `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 | |
| 84 |
| 85 | `glass.magnify(factor)` magnifies what is beneath the glass about its centre, as |
| 86 | a raised lens does (1 for none). It is cheap enough to call every frame, and |
| 87 | sharpens what it magnifies so it doesn't come out soft. |
| 88 |
| 89 | ## Controls on glass |
| 90 |
| 91 | Controls on a glass surface shouldn't get glass of their own. Give the selected |
| 92 | or hovered one the `liquid-glass-well` class and it brightens the glass beneath |
| 93 | it. |
| 94 |
| 95 | ```tsx |
| 96 | <button className={selected ? "liquid-glass-well rounded-full" : "rounded-full"}>Today</button> |
| 97 | ``` |
| 98 |
| 99 | ## Tabs |
| 100 |
| 101 | The track is a glass capsule and the selected tab a well in it. The motion is |
| 102 | iOS 27's tab bar, measured from UIKit recordings. Choosing a tab, one thumb |
| 103 | lifts off the track into a clear lens that magnifies the labels beneath it, |
| 104 | stretches along its path with its speed, and squashes as it drops back in. The |
| 105 | bar swells while pressed, and the press lights the glass under it. Pressing |
| 106 | the selected tab lifts it in place, and dragging it scrubs across the others. A |
| 107 | change of tab the viewer didn't make, such as a route change, slides flat. |
| 108 |
| 109 | ```tsx |
| 110 | import { useLiquidGlassTabs } from "@colineapp/liquid-glass/react"; |
| 111 |
| 112 | export function Period({ value, onChange }) { |
| 113 | const track = useRef(null); |
| 114 | useLiquidGlassTabs(track); |
| 115 | return ( |
| 116 | <div ref={track} role="tablist" className="flex rounded-full p-1"> |
| 117 | {["Day", "Week", "Month"].map((label) => ( |
| 118 | <button key={label} role="tab" aria-selected={label === value} onClick={() => onChange(label)}> |
| 119 | {label} |
| 120 | </button> |
| 121 | ))} |
| 122 | </div> |
| 123 | ); |
| 124 | } |
| 125 | ``` |
| 126 |
| 127 | Without React, `attachLiquidGlassTabs(track, options)` does the same and |
| 128 | returns `update()` and `destroy()`. |
| 129 |
| 130 | - Your tabs and selection stay yours. The thumb follows `aria-selected`, |
| 131 | `aria-checked` or Radix's `data-state="active"`; pass `isSelected` for |
| 132 | anything else. |
| 133 | - A tab the thumb is dragged onto gets pressed and clicked, as a pointer would. |
| 134 | Pass `onSelect` to handle it yourself. |
| 135 | - On another glass surface the track gets no glass of its own: it becomes a |
| 136 | recessed tint. `glass: true` or `false` decides it yourself. |
| 137 | - `orientation: "vertical"` stacks the tabs. Vertical tabs don't scrub. |
| 138 |
| 139 | ## Appearing and leaving |
| 140 |
| 141 | `materialize()` grows the glass from clear to its tone, and `dematerialize()` |
| 142 | fades it back and holds it there, so a menu can open and close without fading |
| 143 | its surface. Wells on it ease back to clear glass as it leaves. |
| 144 |
| 145 | For a surface that zooms as it opens, scale its contents and pass the same |
| 146 | `scale`: the glass grows by its inset instead, since scaling it blanks it. |
| 147 |
| 148 | ```tsx |
| 149 | const glass = useRef<LiquidGlassHandle | null>(null); |
| 150 | useLayoutEffect(() => { |
| 151 | if (open) void glass.current?.materialize({ duration: 240, scale: 0.96 }); |
| 152 | }, [open]); |
| 153 |
| 154 | <LiquidGlass glassRef={glass} floating />; |
| 155 | ``` |
| 156 |
| 157 | For CSS-driven animations, run the `liquid-glass-materialize` and |
| 158 | `liquid-glass-dematerialize` keyframes on `.liquid-glass-layer`. |
| 159 |
| 160 | ## Accessibility |
| 161 |
| 162 | - `prefers-reduced-transparency` replaces the glass with a solid fill of the tint colour. |
| 163 | - `prefers-contrast: more` darkens the sides and turns the edge into a contrasting border. |
| 164 | - `prefers-reduced-motion` keeps the tab thumb from lifting and travelling. |
| 165 | - `forced-colors` hides the glass and outlines the selected tab. |
| 166 |
| 167 | ## How it works |
| 168 |
| 169 | The rim is a squircle-shaped bevel. For each pixel across it, a view ray meets |
| 170 | the tilted surface, refracts by Snell's law and travels through the glass to the |
| 171 | page. The shift is capped wherever it would fall faster than `maxFall` times the |
| 172 | rim moving inward. Past 1, neighbouring pixels would sample in reverse and fold |
| 173 | the page into mirrored echoes; lower caps bend more gently, which the tab lens |
| 174 | uses so labels crossing its rim don't smear. The result becomes an |
| 175 | `feDisplacementMap` in the surface's `backdrop-filter`, plus four masks: the rim |
| 176 | hairline, darkened sides, inner glow, and the darkened edge where the glass |
| 177 | meets the air. Part of the hairline gathers into glints where a light from the |
| 178 | top-left catches the curve, fainter where it returns at the bottom-right. |
| 179 |
| 180 | The lens is rendered once per corner radius as a rounded square of side 2r + 1 |
| 181 | and cut into nine slices. Fixed corners and stretched edges assemble it over a |
| 182 | box of any size, so surfaces can resize and change radius while the glass stays |
| 183 | on. |
| 184 |
| 185 | The tab thumb's four edges each ride their own spring. Travelling, the leading |
| 186 | edge runs on a stiffer one than the trailing edge, so the lens stretches along |
| 187 | its path, then settles critically damped, since any overshoot would leave the |
| 188 | glass wobbling by sub-pixels. |
| 189 |
| 190 | `createGlassSlices()` exposes the lens as raw pixels if you want to use it |
| 191 | somewhere else. |
| 192 |
| 193 | ## Known gaps |
| 194 |
| 195 | This is a close approximation of Apple's glass, not a copy. Pull requests are |
| 196 | welcome for any of these: |
| 197 |
| 198 | - **Glass doesn't squish or merge.** Apple's glass stretches when pressed and |
| 199 | merges and splits as surfaces move near each other. Here the tab thumb |
| 200 | stretches as it travels, interactive glass lights up under the pointer, and |
| 201 | surfaces materialize, but nothing deforms on press or merges. |
| 202 | - **Light doesn't follow the device.** The rim's glints are lit from a fixed |
| 203 | top-left light. They don't move with the pointer or device tilt. |
| 204 | - **Refraction is Chromium only.** Safari and Firefox don't run SVG filters in |
| 205 | `backdrop-filter`, so they get the material and rim light without the bend. |
| 206 | - **Only the rim bends.** The flat middle of the surface shows the page |
| 207 | unmagnified, and there is no chromatic dispersion at the edge. |
| 208 | - **One radius per surface.** The lens takes `border-top-left-radius` for every |
| 209 | corner, so surfaces with mixed corner radii or non-rectangular shapes get the |
| 210 | wrong rim. |
| 211 | - **Light regular glass can look solid.** Over pale pages its body lifts close |
| 212 | to white. The tones were tuned by eye against iOS screenshots; measured values |
| 213 | would do better. |
| 214 | - **Scaling the surface blanks it in Chromium** for a few frames, so menus and |
| 215 | dialogs can't use a scale-in animation on the surface itself (see Appearing |
| 216 | and leaving). |
| 217 |
| 218 | ## Demo |
| 219 |
| 220 | The playground and docs are at https://liquid-glass.coline.space, |
| 221 | with plain-text docs for agents at `/llms.txt` and `/agents.md`. `demo/` is |
| 222 | that site, a Coline Site built on the published package. Run it with |
| 223 | `npm install && npm run dev` inside that folder. |
| 224 |
| 225 | ## Source |
| 226 |
| 227 | The repository is [coline/liquid-glass](https://upstream.codes/coline/liquid-glass) |
| 228 | on upstream.codes: |
| 229 |
| 230 | ```sh |
| 231 | git clone https://git.coline.app/coline/liquid-glass.git |
| 232 | ``` |
| 233 |
| 234 | The package is developed inside Coline's monorepo, at `packages/liquid-glass`, |
| 235 | next to the app whose glass it ships, and this repository is published from |
| 236 | there. Issues and change requests are welcome here and get ported back. In the |
| 237 | monorepo, `scripts/sync-liquid-glass.sh` republishes it. It splits |
| 238 | `packages/liquid-glass` out of `origin/0.7` with `git subtree split`, which |
| 239 | gives the same commits every time, and pushes the result here as `main`, so |
| 240 | every sync is a fast-forward. |
| 241 |
| 242 | ## License |
| 243 |
| 244 | MIT |
| 245 |