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