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 | # @colineapp/liquid-glass |
| 2 | |
| 3 | > Liquid Glass for the web: the rim refraction, light and motion of iOS 27's glass on any rounded element, with React bindings and tabs whose thumb lifts into a lens. MIT. |
| 4 | |
| 5 | Version 0.1.0. Install: `npm install @colineapp/liquid-glass`. npm: https://www.npmjs.com/package/@colineapp/liquid-glass. Playground: https://liquid-glass-e55033.coline.space. |
| 6 | |
| 7 | ## Quick start |
| 8 | |
| 9 | ```sh |
| 10 | npm install @colineapp/liquid-glass |
| 11 | ``` |
| 12 | |
| 13 | ```tsx |
| 14 | import { LiquidGlass } from "@colineapp/liquid-glass/react"; |
| 15 | import "@colineapp/liquid-glass/styles.css"; |
| 16 | |
| 17 | <nav className="relative rounded-full px-6 py-3"> |
| 18 | <LiquidGlass appearance="class" /> |
| 19 | … |
| 20 | </nav> |
| 21 | ``` |
| 22 | |
| 23 | ## Rules |
| 24 | |
| 25 | - Put the glass first inside a positioned (relative, absolute or fixed), rounded element. It takes the element's corner radius and follows its size. |
| 26 | - Paint nothing over it: no background, border or box-shadow on the surface. The glass draws its own edge. |
| 27 | - No opacity below 1, filter, mask or backdrop-filter on the surface or its ancestors while the glass shows. Each makes a backdrop root, and the glass would see only that element instead of the page. |
| 28 | - To show or hide a surface, fade and zoom its contents, never the surface itself, and call materialize() and dematerialize() for the glass. |
| 29 | - Controls on glass get no glass of their own: give the selected or hovered one the liquid-glass-well class. |
| 30 | - Rim refraction renders in Chromium. Safari and Firefox get the same material, light and motion without the bend. |
| 31 | |
| 32 | ## Glass options |
| 33 | |
| 34 | Options for `<LiquidGlass />`, `useLiquidGlass(ref, options)`, `createLiquidGlass(element, options)` and `glass.update(options)`. Every option is optional. |
| 35 | |
| 36 | ### appearance |
| 37 | |
| 38 | - Type: `"system" | "class" | "dark" | "light"` |
| 39 | - Default: `"system"` |
| 40 | - Group: Glass options |
| 41 | |
| 42 | `system` follows prefers-color-scheme. `class` is dark while `<html>` has the `dark` class (Tailwind's class strategy, next-themes). `dark` and `light` fix it. |
| 43 | |
| 44 | ```tsx |
| 45 | <LiquidGlass appearance="class" /> |
| 46 | ``` |
| 47 | |
| 48 | ### variant |
| 49 | |
| 50 | - Type: `"regular" | "clear" | "lens"` |
| 51 | - Default: `"regular"` |
| 52 | - Group: Glass options |
| 53 | |
| 54 | `regular` diffuses and evens out the page so text on the glass stays legible over anything. `clear` only bends and lights the page, for glass over media. `lens` is the clear lens a control lifts into; pair it with `geometry: LENS_GEOMETRY`. |
| 55 | |
| 56 | ```tsx |
| 57 | <LiquidGlass variant="clear" /> |
| 58 | ``` |
| 59 | |
| 60 | ### floating |
| 61 | |
| 62 | - Type: `boolean | "dialog"` |
| 63 | - Default: `false` |
| 64 | - Group: Glass options |
| 65 | |
| 66 | For menus, popovers, dialogs and sheets. Floating glass diffuses the page more, as iOS 27's does, so text beneath washes into soft colour, and the surface casts a drop shadow (`--liquid-glass-shadow`). `"dialog"` casts a dialog's deeper shadow (`--liquid-glass-dialog-shadow`). |
| 67 | |
| 68 | ```tsx |
| 69 | <LiquidGlass floating="dialog" /> |
| 70 | ``` |
| 71 | |
| 72 | ### interactive |
| 73 | |
| 74 | - Type: `boolean` |
| 75 | - Default: `false` |
| 76 | - Group: Glass options |
| 77 | |
| 78 | For glass that holds controls, like SwiftUI's `.glassEffect(.regular.interactive())`: the pointer warms the glass beneath it, and a press blooms out from there. |
| 79 | |
| 80 | ```tsx |
| 81 | <LiquidGlass interactive /> |
| 82 | ``` |
| 83 | |
| 84 | ### swell |
| 85 | |
| 86 | - Type: `boolean` |
| 87 | - Default: `false` |
| 88 | - Group: Glass options |
| 89 | |
| 90 | For glass you press, like a search field: pressed, the surface swells about its centre on the tabs' spring and springs back with a slight overshoot on release. |
| 91 | |
| 92 | ```tsx |
| 93 | <LiquidGlass interactive swell /> |
| 94 | ``` |
| 95 | |
| 96 | ### tint |
| 97 | |
| 98 | - Type: `string` |
| 99 | - Default: near-black in dark, white in light |
| 100 | - Group: Glass options |
| 101 | |
| 102 | Colour washed over the body by the tone's `tint` share (50% on dark regular glass, 40% on light). Any CSS colour, including `var()`. |
| 103 | |
| 104 | ```tsx |
| 105 | <LiquidGlass tint="var(--background)" /> |
| 106 | ``` |
| 107 | |
| 108 | ### hue |
| 109 | |
| 110 | - Type: `string | null` |
| 111 | - Default: `null` |
| 112 | - Group: Glass options |
| 113 | |
| 114 | A colour whose hue the glass takes on, like tinted iOS glass: each pixel beneath shifts toward the hue at its own brightness, so colours behind keep their chroma. Any concrete CSS colour; neutral colours leave the glass untinted. |
| 115 | |
| 116 | ```tsx |
| 117 | <LiquidGlass hue="#3d6bff" /> |
| 118 | ``` |
| 119 | |
| 120 | ### hueStrength |
| 121 | |
| 122 | - Type: `number (0–1)` |
| 123 | - Default: 1 on light glass, 0.5 on dark |
| 124 | - Group: Glass options |
| 125 | |
| 126 | How much of the hue the glass takes. |
| 127 | |
| 128 | ```tsx |
| 129 | <LiquidGlass hue="#ff4fa3" hueStrength={0.7} /> |
| 130 | ``` |
| 131 | |
| 132 | ### geometry |
| 133 | |
| 134 | - Type: `Partial<GlassGeometry>` |
| 135 | - Default: DEFAULT_GEOMETRY |
| 136 | - Group: Glass options |
| 137 | |
| 138 | The rim's shape, measured from iOS 27: its width and depth, how steeply it bends, the darkened edge and the glints. See the geometry fields below. `LENS_GEOMETRY` is the rim for a control-high lens. |
| 139 | |
| 140 | ```tsx |
| 141 | <LiquidGlass geometry={{ maxFall: 0.5, edge: 1 }} /> |
| 142 | ``` |
| 143 | |
| 144 | ### tone |
| 145 | |
| 146 | - Type: `{ dark?: Partial<GlassTone>; light?: Partial<GlassTone> }` |
| 147 | - Group: Glass options |
| 148 | |
| 149 | Per-appearance overrides of the variant's filters: body `blur`, `floatingBlur`, `saturate`, `contrast`, `brightness`, `tint` share, and the rim's `highlightContrast`, `highlightBrightness`, `shadeBrightness`, `glowSaturate`, `glowBrightness`, `edgeBrightness`. |
| 150 | |
| 151 | ```tsx |
| 152 | <LiquidGlass tone={{ dark: { brightness: 0.4 }, light: { tint: 0.5 } }} /> |
| 153 | ``` |
| 154 | |
| 155 | ### fit |
| 156 | |
| 157 | - Type: `"box" | "fixed" | "scroll"` |
| 158 | - Default: `"box"` |
| 159 | - Group: Glass options |
| 160 | |
| 161 | `box` fills the surface. `fixed` fills the nearest containing block for fixed elements, such as a transformed popper wrapper, so the glass holds still while the surface scrolls its own content. `scroll` spans the surface's whole scroll height, for a dialog or sheet that scrolls itself. |
| 162 | |
| 163 | ```tsx |
| 164 | <LiquidGlass fit="scroll" floating="dialog" /> |
| 165 | ``` |
| 166 | |
| 167 | ### refraction |
| 168 | |
| 169 | - Type: `boolean` |
| 170 | - Default: `true` |
| 171 | - Group: Glass options |
| 172 | |
| 173 | Bend the page at the rim. Rim refraction needs SVG filters in backdrop-filter, which only Chromium renders; Safari and Firefox keep the material without the bend either way. |
| 174 | |
| 175 | ```tsx |
| 176 | <LiquidGlass refraction={false} /> |
| 177 | ``` |
| 178 | |
| 179 | ### samples |
| 180 | |
| 181 | - Type: `1 | 4` |
| 182 | - Default: `1` |
| 183 | - Group: Glass options |
| 184 | |
| 185 | Refraction samples per pixel. 4 antialiases the bend, so edges it stretches don't stair-step as the glass moves. It costs four displacement passes, so keep it for small glass with nothing blurring its backdrop, like a lens. |
| 186 | |
| 187 | ```tsx |
| 188 | <LiquidGlass variant="lens" samples={4} /> |
| 189 | ``` |
| 190 | |
| 191 | ## Geometry fields |
| 192 | |
| 193 | Fields of `GlassGeometry`, in CSS pixels unless noted. Pass any subset as `geometry`. |
| 194 | |
| 195 | ### bezel |
| 196 | |
| 197 | - Type: `number` |
| 198 | - Default: `17` |
| 199 | - Group: Geometry fields |
| 200 | |
| 201 | Width of the refracting rim along the top and bottom. The centre is flat. |
| 202 | |
| 203 | ### thickness |
| 204 | |
| 205 | - Type: `number` |
| 206 | - Default: `24` |
| 207 | - Group: Geometry fields |
| 208 | |
| 209 | Glass depth under the top and bottom rims. Deeper glass bends further. |
| 210 | |
| 211 | ### sideBezel |
| 212 | |
| 213 | - Type: `number` |
| 214 | - Default: `27` |
| 215 | - Group: Geometry fields |
| 216 | |
| 217 | Width of the refracting rim at the sides and rounded ends. |
| 218 | |
| 219 | ### sideThickness |
| 220 | |
| 221 | - Type: `number` |
| 222 | - Default: `44` |
| 223 | - Group: Geometry fields |
| 224 | |
| 225 | Glass depth under the sides and rounded ends. |
| 226 | |
| 227 | ### refractiveIndex |
| 228 | |
| 229 | - Type: `number` |
| 230 | - Default: `1.5` |
| 231 | - Group: Geometry fields |
| 232 | |
| 233 | The glass's refractive index, for Snell's law at the rim. |
| 234 | |
| 235 | ### maxFall |
| 236 | |
| 237 | - Type: `number (0–1)` |
| 238 | - Default: `0.8` |
| 239 | - Group: Geometry fields |
| 240 | |
| 241 | Rim bend: the steepest the rim's shift may fall per pixel inward. Where it falls, the rim magnifies what is beneath by 1 / (1 − maxFall), so lower values bend more gently and keep text crossing the rim from smearing. The tab lens uses 0.3. Past 1 the lens folds content into mirrored echoes. |
| 242 | |
| 243 | ### edge |
| 244 | |
| 245 | - Type: `number` |
| 246 | - Default: `0.75` |
| 247 | - Group: Geometry fields |
| 248 | |
| 249 | Width of iOS 27's darkened edge, a contour where the glass meets the air, with the hairline just inside it. 0 leaves it out. |
| 250 | |
| 251 | ### glint |
| 252 | |
| 253 | - Type: `number (0–1)` |
| 254 | - Default: `0.4` |
| 255 | - Group: Geometry fields |
| 256 | |
| 257 | Share of the hairline's light that gathers into glints where the light catches the curve, at the top-left and, fainter, the bottom-right. 0 keeps an even band. |
| 258 | |
| 259 | ### hairline |
| 260 | |
| 261 | - Type: `number` |
| 262 | - Default: `1` |
| 263 | - Group: Geometry fields |
| 264 | |
| 265 | Rim hairline width. |
| 266 | |
| 267 | ### hairlineSides |
| 268 | |
| 269 | - Type: `number (0–1)` |
| 270 | - Default: `0.15` |
| 271 | - Group: Geometry fields |
| 272 | |
| 273 | Share of the hairline's strength that reaches the sides. |
| 274 | |
| 275 | ### shadeWidth |
| 276 | |
| 277 | - Type: `number` |
| 278 | - Default: `1` |
| 279 | - Group: Geometry fields |
| 280 | |
| 281 | Width of the darkened sides. |
| 282 | |
| 283 | ### glowReach |
| 284 | |
| 285 | - Type: `number` |
| 286 | - Default: `8` |
| 287 | - Group: Geometry fields |
| 288 | |
| 289 | How far the inner glow reaches in from the top and bottom rims. |
| 290 | |
| 291 | ### glowBottom |
| 292 | |
| 293 | - Type: `number (0–1)` |
| 294 | - Default: `0.5` |
| 295 | - Group: Geometry fields |
| 296 | |
| 297 | Bottom glow strength relative to the top. |
| 298 | |
| 299 | ## The glass handle |
| 300 | |
| 301 | `createLiquidGlass()` and `attachLiquidGlass()` return it; `useLiquidGlass()` returns a ref to it, and `<LiquidGlass glassRef={ref} />` fills one. |
| 302 | |
| 303 | ### update(options) |
| 304 | |
| 305 | - Type: `(options: LiquidGlassOptions) => void` |
| 306 | - Group: The glass handle |
| 307 | |
| 308 | Replaces the options. Cheap: the rim only rebuilds when the geometry changes. |
| 309 | |
| 310 | ```ts |
| 311 | glass.update({ variant: "clear" }); |
| 312 | ``` |
| 313 | |
| 314 | ### magnify(factor) |
| 315 | |
| 316 | - Type: `(factor: number) => void` |
| 317 | - Group: The glass handle |
| 318 | |
| 319 | Magnifies what is beneath the glass about its centre, as a raised lens does (1 for none), and sharpens it so it doesn't come out soft. Cheap enough to call every frame. Needs refraction. |
| 320 | |
| 321 | ```ts |
| 322 | glass.magnify(1.1); |
| 323 | ``` |
| 324 | |
| 325 | ### materialize(timing?) |
| 326 | |
| 327 | - Type: `(timing?: { duration?, easing?, delay?, scale? }) => Promise<void>` |
| 328 | - Group: The glass handle |
| 329 | |
| 330 | Grows the glass from clear to its tone, for surfaces that appear. Fade the surface's contents, never the surface: opacity below 1 hides the glass. For a surface that zooms open, scale its contents and pass the same `scale`; the glass grows by its inset instead. |
| 331 | |
| 332 | ```ts |
| 333 | await glass.materialize({ duration: 240, scale: 0.96 }); |
| 334 | ``` |
| 335 | |
| 336 | ### dematerialize(timing?) |
| 337 | |
| 338 | - Type: `(timing?) => Promise<void>` |
| 339 | - Group: The glass handle |
| 340 | |
| 341 | Fades the glass back to clear and holds it there, for surfaces that leave. Wells on it ease back with it. |
| 342 | |
| 343 | ```ts |
| 344 | await glass.dematerialize({ duration: 160 }); |
| 345 | ``` |
| 346 | |
| 347 | ### destroy() |
| 348 | |
| 349 | - Type: `() => void` |
| 350 | - Group: The glass handle |
| 351 | |
| 352 | Removes the glass and its filters. |
| 353 | |
| 354 | ### element |
| 355 | |
| 356 | - Type: `HTMLElement` |
| 357 | - Group: The glass handle |
| 358 | |
| 359 | The glass element, the surface's first child. |
| 360 | |
| 361 | ### appearance |
| 362 | |
| 363 | - Type: `"dark" | "light"` |
| 364 | - Group: The glass handle |
| 365 | |
| 366 | The resolved appearance. |
| 367 | |
| 368 | ## Tabs |
| 369 | |
| 370 | `useLiquidGlassTabs(trackRef, options)` in React, `attachLiquidGlassTabs(track, options)` without it. The track is the tabs' positioned, rounded container. Your tabs and selection stay yours; the thumb follows the selected tab, lifts into a lens that magnifies the labels, stretches as it travels and squashes as it lands. Dragging the selected tab scrubs across the others. Returns `{ update(), destroy() }`. |
| 371 | |
| 372 | ### appearance |
| 373 | |
| 374 | - Type: `"system" | "class" | "dark" | "light"` |
| 375 | - Default: `"system"` |
| 376 | - Group: Tabs |
| 377 | |
| 378 | As for glass. |
| 379 | |
| 380 | ### orientation |
| 381 | |
| 382 | - Type: `"horizontal" | "vertical"` |
| 383 | - Default: `"horizontal"` |
| 384 | - Group: Tabs |
| 385 | |
| 386 | Vertical tabs stack and don't scrub. |
| 387 | |
| 388 | ### tabs |
| 389 | |
| 390 | - Type: `string` |
| 391 | - Default: '[role="tab"]' |
| 392 | - Group: Tabs |
| 393 | |
| 394 | Selector for the tabs inside the track. |
| 395 | |
| 396 | ```ts |
| 397 | useLiquidGlassTabs(track, { tabs: '[role="radio"]' }); |
| 398 | ``` |
| 399 | |
| 400 | ### isSelected |
| 401 | |
| 402 | - Type: `(tab: HTMLElement) => boolean` |
| 403 | - Default: aria-selected, aria-checked or data-state="active" |
| 404 | - Group: Tabs |
| 405 | |
| 406 | Whether a tab is selected. The thumb follows changes to the default attributes by itself; call `update()` after changes to anything else. |
| 407 | |
| 408 | ### onSelect |
| 409 | |
| 410 | - Type: `(tab: HTMLElement) => void` |
| 411 | - Default: press and click the tab |
| 412 | - Group: Tabs |
| 413 | |
| 414 | Selects the tab the thumb was dragged onto. |
| 415 | |
| 416 | ### glass |
| 417 | |
| 418 | - Type: `boolean | LiquidGlassOptions` |
| 419 | - Default: glass unless on another glass surface |
| 420 | - Group: Tabs |
| 421 | |
| 422 | The track's own glass, or its options. On another glass surface the track gets none of its own: it becomes a recessed tint and the thumb a well in that glass. |
| 423 | |
| 424 | ### refraction |
| 425 | |
| 426 | - Type: `boolean` |
| 427 | - Default: `true` |
| 428 | - Group: Tabs |
| 429 | |
| 430 | Bend the page at the rims. |
| 431 | |
| 432 | ## React |
| 433 | |
| 434 | From `@colineapp/liquid-glass/react`. React 18 or later; the core package has no dependencies. |
| 435 | |
| 436 | ### <LiquidGlass /> |
| 437 | |
| 438 | - Type: `LiquidGlassOptions & { className?, style?, children?, glassRef? }` |
| 439 | - Group: React |
| 440 | |
| 441 | Glass behind a surface's content. Render it first inside a positioned, rounded element. `glassRef` receives the handle, for materialize() and magnify(). |
| 442 | |
| 443 | ```tsx |
| 444 | <nav className="relative rounded-full px-6 py-3"> |
| 445 | <LiquidGlass interactive /> |
| 446 | … |
| 447 | </nav> |
| 448 | ``` |
| 449 | |
| 450 | ### useLiquidGlass(ref, options) |
| 451 | |
| 452 | - Type: `RefObject<LiquidGlass | null>` |
| 453 | - Group: React |
| 454 | |
| 455 | Turns an element you already render into a glass surface; the glass is inserted as its first child. |
| 456 | |
| 457 | ### useLiquidGlassTabs(ref, options) |
| 458 | |
| 459 | - Type: `RefObject<LiquidGlassTabs | null>` |
| 460 | - Group: React |
| 461 | |
| 462 | Liquid Glass tabs on a track you render. See Tabs. |
| 463 | |
| 464 | ## CSS |
| 465 | |
| 466 | Import `@colineapp/liquid-glass/styles.css` once. The filters themselves are set inline by the script, as literal values Safari can read. |
| 467 | |
| 468 | ### .liquid-glass-well |
| 469 | |
| 470 | - Type: `class` |
| 471 | - Group: CSS |
| 472 | |
| 473 | For a selected or hovered control on a glass surface: brightens the glass beneath it (dimmer in light), instead of glass on glass. |
| 474 | |
| 475 | ```tsx |
| 476 | <button className="liquid-glass-well rounded-full">Today</button> |
| 477 | ``` |
| 478 | |
| 479 | ### --liquid-glass-shadow |
| 480 | |
| 481 | - Type: `custom property` |
| 482 | - Group: CSS |
| 483 | |
| 484 | The shadow floating glass casts. Set it on the surface or an ancestor. |
| 485 | |
| 486 | ### --liquid-glass-dialog-shadow |
| 487 | |
| 488 | - Type: `custom property` |
| 489 | - Group: CSS |
| 490 | |
| 491 | The deeper shadow of `floating: "dialog"` glass. |
| 492 |