# @colineapp/liquid-glass
> Liquid Glass for the web: the rim refraction, light and motion of iOS 27's glass on any rounded element, with React bindings, menus that pour out of their button, and segmented tabs whose thumb clears into a lens. MIT.
Version 0.2.0. Install: `npm install @colineapp/liquid-glass`. npm: https://www.npmjs.com/package/@colineapp/liquid-glass. Source: https://upstream.codes/coline/liquid-glass. Playground: https://liquid-glass.coline.space.
## Quick start
```sh
npm install @colineapp/liquid-glass
```
```tsx
import { LiquidGlass } from "@colineapp/liquid-glass/react";
import "@colineapp/liquid-glass/styles.css";
```
## Rules
- Put the glass first inside a positioned (relative, absolute or fixed), rounded element. It takes the element's corner radius and follows its size.
- Paint nothing over it: no background, border or box-shadow on the surface. The glass draws its own edge.
- 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.
- To show or hide a dialog or sheet, fade and zoom its contents, never the surface itself, and call materialize() and dematerialize() for the glass. Menus and popovers pour out of their button with pourFrom() and draw back with drawBack().
- Controls on glass get no glass of their own: give the selected or hovered one the liquid-glass-well class, or slide one between rows with attachLiquidGlassHighlight.
- Rim refraction renders in Chromium. Safari and Firefox get the same material, light and motion without the bend.
## Glass options
Options for ``, `useLiquidGlass(ref, options)`, `createLiquidGlass(element, options)` and `glass.update(options)`. Every option is optional.
### appearance
- Type: `"system" | "class" | "dark" | "light"`
- Default: `"system"`
- Group: Glass options
`system` follows prefers-color-scheme. `class` is dark while `` has the `dark` class (Tailwind's class strategy, next-themes). `dark` and `light` fix it.
```tsx
```
### variant
- Type: `"regular" | "clear" | "lens"`
- Default: `"regular"`
- Group: Glass options
`regular` diffuses the page so text on the glass stays legible over anything, as clear or tinted as its `level`. `clear` is regular glass at level 0, iOS 27's More Clear: the page barely changes but for a trace of wash and the rim, for glass over media. `lens` is the clear lens a control lifts into; pair it with `geometry: LENS_GEOMETRY`.
```tsx
```
### level
- Type: `number (0–1)`
- Default: `0.5`
- Group: Glass options
How clear or tinted regular glass is, as iOS 27's Liquid Glass slider sets it: 0 is More Clear, 0.5 a light frost and a faint wash, 1 More Tinted (iOS 27's own default), anything in between. Light glass washes and frosts the page; dark glass is a smoky film that keeps the colour beneath. Floating glass keeps enough frost to read at every level. The rim doesn't change with it.
```tsx
```
### floating
- Type: `boolean | "dialog"`
- Default: `false`
- Group: Glass options
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`).
```tsx
```
### interactive
- Type: `boolean`
- Default: `false`
- Group: Glass options
For glass that holds controls, like SwiftUI's `.glassEffect(.regular.interactive())`: 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`).
```tsx
```
### swell
- Type: `boolean`
- Default: as interactive
- Group: Glass options
Pressed, the surface swells about its centre and springs back with a slight overshoot on release; held and dragged, it leans after the pointer like taffy, stretching along the pull. On for interactive glass unless set to false; on its own for glass you press, like a search field. Buttons that open a menu or dialog don't swell, so what opens from them holds still.
```tsx
```
### shape
- Type: `string | (width: number, height: number) => string`
- Group: Glass options
Glass of an outline no rounded rectangle makes: an SVG path of absolute M, L, H, V, C and Z commands in the surface's CSS pixels, or a function from its size to one. Each pixel is shaded from its distance to the outline, so the rim, glints and bend follow it. It is rendered whole for each size, a few hundred milliseconds for a large surface, so keep it to surfaces that don't resize. The rims stay within the surface's border-radius.
```tsx
```
### tint
- Type: `string`
- Default: near-black in dark, white in light
- Group: Glass options
Colour washed over the body by the tone's `tint` share (about a sixth on light regular glass at the default level, a quarter on dark, more on floating glass and toward More Tinted). Any CSS colour, including `var()`.
```tsx
```
### hue
- Type: `string | null`
- Default: `null`
- Group: Glass options
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.
```tsx
```
### hueStrength
- Type: `number (0–1)`
- Default: 1 on light glass, 0.5 on dark
- Group: Glass options
How much of the hue the glass takes.
```tsx
```
### geometry
- Type: `Partial`
- Default: DEFAULT_GEOMETRY
- Group: Glass options
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.
```tsx
```
### tone
- Type: `{ dark?: Partial; light?: Partial }`
- Group: Glass options
Per-appearance overrides of the variant's filters: body `blur`, `saturate`, `contrast`, `brightness` and `tint` share, the same for floating glass (`floatingBlur`, `floatingSaturate`, `floatingContrast`, `floatingBrightness`, `floatingTint`), and the rim's `highlightContrast`, `highlightBrightness`, `shadeBrightness`, `glowSaturate`, `glowBrightness`, `edgeBrightness`.
```tsx
```
### fit
- Type: `"box" | "fixed" | "scroll"`
- Default: `"box"`
- Group: Glass options
`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.
```tsx
```
### refraction
- Type: `boolean`
- Default: `true`
- Group: Glass options
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.
```tsx
```
### samples
- Type: `1 | 4`
- Default: `4`
- Group: Glass options
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 over the rim. 1 is cheaper for large glass.
```tsx
```
## Geometry fields
Fields of `GlassGeometry`, in CSS pixels unless noted. Pass any subset as `geometry`.
### bezel
- Type: `number`
- Default: `17`
- Group: Geometry fields
Width of the refracting rim along the top and bottom. The centre is flat.
### thickness
- Type: `number`
- Default: `24`
- Group: Geometry fields
Glass depth under the top and bottom rims. Deeper glass bends further.
### sideBezel
- Type: `number`
- Default: `27`
- Group: Geometry fields
Width of the refracting rim at the sides and rounded ends.
### sideThickness
- Type: `number`
- Default: `44`
- Group: Geometry fields
Glass depth under the sides and rounded ends.
### refractiveIndex
- Type: `number`
- Default: `1.5`
- Group: Geometry fields
The glass's refractive index, for Snell's law at the rim.
### maxFall
- Type: `number (0–1)`
- Default: `0.45`
- Group: Geometry fields
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), at most 1.8 times by default, so lower values bend more gently and keep text crossing the rim from smearing. The tab lens uses 0.22. Past 1 the lens folds content into mirrored echoes.
### rimShare
- Type: `number (0–1)`
- Default: `0.6`
- Group: Geometry fields
Most of the corner radius the top and bottom rims may take. On small glass a full-width rim reaches the middle, so the whole body is rim and magnifies what is beneath; iOS keeps the bend to a band at the edge and shows the middle as it is.
### edge
- Type: `number`
- Default: `0.75`
- Group: Geometry fields
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.
### glint
- Type: `number (0–1)`
- Default: `0.4`
- Group: Geometry fields
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.
### hairline
- Type: `number`
- Default: `1`
- Group: Geometry fields
Rim hairline width.
### hairlineSides
- Type: `number (0–1)`
- Default: `0.15`
- Group: Geometry fields
Share of the hairline's strength that reaches the sides.
### shadeWidth
- Type: `number`
- Default: `1`
- Group: Geometry fields
Width of the darkened sides.
### glowReach
- Type: `number`
- Default: `8`
- Group: Geometry fields
How far the inner glow reaches in from the top and bottom rims.
### glowBottom
- Type: `number (0–1)`
- Default: `0.8`
- Group: Geometry fields
Bottom glow strength relative to the top: nearly the top's, as the light iOS 27's glass gathers along its lower edge is.
## The glass handle
`createLiquidGlass()` and `attachLiquidGlass()` return it; `useLiquidGlass()` returns a ref to it, and `` fills one.
### update(options)
- Type: `(options: LiquidGlassOptions) => void`
- Group: The glass handle
Replaces the options. Cheap: the rim only rebuilds when the geometry changes.
```ts
glass.update({ variant: "clear" });
```
### magnify(factor)
- Type: `(factor: number) => void`
- Group: The glass handle
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.
```ts
glass.magnify(1.1);
```
### materialize(timing?)
- Type: `(timing?: { duration?, easing?, delay?, scale? }) => Promise`
- Group: The glass handle
Grows the glass from clear to its tone, for surfaces that appear, over 240ms on ease-out cubic by default. 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.
```ts
await glass.materialize({ scale: 0.965 });
```
### dematerialize(timing?)
- Type: `(timing?) => Promise`
- Group: The glass handle
Fades the glass back to clear and holds it there, for surfaces that leave, over 180ms by default. Wells on it ease back with it.
```ts
await glass.dematerialize({ scale: 0.98 });
```
### pourFrom(source, options?)
- Type: `(source: Element | { x: number; y: number }, options?: { cover?, side?, submenu? }) => Promise`
- Group: The glass handle
Pours a menu or popover out of the button that opened it, as iOS 27's do: the glass starts as the button and inflates into the surface, its height leading and overshooting, while the contents ride inside it and sharpen as it lands. `cover: true` moves the surface over its button (up to 56px tall), so the button becomes the menu. Pass a point for a context menu, `submenu: true` for a sub-menu unfolding beside its row. Call it once the surface is shown and placed. Resolves true once landed, false if drawBack() interrupted it.
```ts
await glass.pourFrom(button, { cover: true });
```
### drawBack()
- Type: `() => Promise`
- Group: The glass handle
Draws the surface back into where it poured from: the contents go first and the glass rounds into a drop that shrinks into the button, which reappears with a bulge. Presses go through to what it covered meanwhile. Resolves true once the glass has gone, when you hide the surface, or false if pourFrom() reopened it.
```ts
if (await glass.drawBack()) setOpen(false);
```
### destroy()
- Type: `() => void`
- Group: The glass handle
Removes the glass and its filters.
### element
- Type: `HTMLElement`
- Group: The glass handle
The glass element, the surface's first child.
### appearance
- Type: `"dark" | "light"`
- Group: The glass handle
The resolved appearance.
## Tabs
`useLiquidGlassTabs(trackRef, options)` in React, `attachLiquidGlassTabs(track, options)` without it. The track is the tabs' positioned, rounded container. A horizontal track is iOS 27's segmented control: the selected tab is a solid thumb (white in light, grey in dark) that clears into a clear lens over the labels as it travels, stretches with its speed, hangs at its tab a moment and refills as it lands. Dragging the selected tab scrubs across the others. A vertical list is a sidebar, whose selected tab is a well in the glass. Your tabs and selection stay yours. Wrap labels in `` for the semibold selected label. Returns `{ update(), destroy() }`.
### appearance
- Type: `"system" | "class" | "dark" | "light"`
- Default: `"system"`
- Group: Tabs
As for glass.
### orientation
- Type: `"horizontal" | "vertical"`
- Default: `"horizontal"`
- Group: Tabs
Vertical tabs stack and don't scrub.
### tabs
- Type: `string`
- Default: '[role="tab"]'
- Group: Tabs
Selector for the tabs inside the track.
```ts
useLiquidGlassTabs(track, { tabs: '[role="radio"]' });
```
### isSelected
- Type: `(tab: HTMLElement) => boolean`
- Default: aria-selected, aria-checked or data-state="active"
- Group: Tabs
Whether a tab is selected. The thumb follows changes to the default attributes by itself; call `update()` after changes to anything else.
### onSelect
- Type: `(tab: HTMLElement) => void`
- Default: press and click the tab
- Group: Tabs
Selects the tab the thumb was dragged onto.
### glass
- Type: `boolean | LiquidGlassOptions`
- Default: glass unless on another glass surface
- Group: Tabs
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.
### refraction
- Type: `boolean`
- Default: `true`
- Group: Tabs
Bend the page at the rims.
## React
From `@colineapp/liquid-glass/react`. React 18 or later; the core package has no dependencies.
###
- Type: `LiquidGlassOptions & { className?, style?, children?, glassRef? }`
- Group: React
Glass behind a surface's content. Render it first inside a positioned, rounded element. `glassRef` receives the handle, for materialize() and magnify().
```tsx
```
### useLiquidGlass(ref, options)
- Type: `RefObject`
- Group: React
Turns an element you already render into a glass surface; the glass is inserted as its first child.
### useLiquidGlassTabs(ref, options)
- Type: `RefObject`
- Group: React
Liquid Glass tabs on a track you render. See Tabs.
###
- Type: `{ children: string }`
- Group: React
A tab's text, semibold while its tab is selected (or a drag would land on it), as iOS 27's segmented control sets it, without the tab changing width.
```tsx
```
### useLiquidGlassHighlight(ref, options?)
- Type: `RefObject`
- Group: React
A hover pill for the rows of a glass menu or list: a well that slides to the row under the pointer or with focus. See attachLiquidGlassHighlight.
```ts
useLiquidGlassHighlight(menu);
```
## More exports
From `@colineapp/liquid-glass`.
### attachLiquidGlassHighlight(surface, options?)
- Type: `(surface: HTMLElement, options?: { items?: string }) => { update(), destroy() }`
- Group: More exports
Gives a glass surface's rows a hover pill: 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. Finds menu items and options by default.
```ts
attachLiquidGlassHighlight(menu, { items: '[role="option"]' });
```
### prewarmLiquidGlass(radii?, geometry?)
- Type: `(radii?: number[], geometry?: Partial) => void`
- Group: More exports
Builds the rim for these corner radii while the page is idle, so glass whose corners animate through them is sharp on every frame. By default, every radius a pouring menu passes through (4 to 48px). Floating glass does this when it first appears; call it at startup if your menus mount only as they open.
```ts
prewarmLiquidGlass();
```
### createShapedGlass(path, width, height, radius, pixelRatio?, geometry?)
- Type: `ShapedGlass`
- Group: More exports
The refraction map and masks for an outline, as raw pixels, as the `shape` option renders them.
### createGlassSlices(radius, pixelRatio?, geometry?)
- Type: `GlassSlices`
- Group: More exports
The lens for a rounded box as raw pixels: a rounded square of side 2r + 1 that assembles over any size.
### kickGlass(element, velocity)
- Type: `(element: Element | null, velocity: number) => void`
- Group: More exports
Kicks the swell of the glass around `element`: positive bulges it, negative makes it give, and it rings back to rest. A drawn-back menu does this to its button's glass.
```ts
kickGlass(button, 22);
```
## CSS
Import `@colineapp/liquid-glass/styles.css` once. The filters themselves are set inline by the script, as literal values Safari can read.
### .liquid-glass-well
- Type: `class`
- Group: CSS
For a selected or hovered control on a glass surface: brightens the glass beneath it (dimmer in light), instead of glass on glass.
```tsx
```
### --liquid-glass-shadow
- Type: `custom property`
- Group: CSS
The shadow floating glass casts. Set it on the surface or an ancestor.
### --liquid-glass-dialog-shadow
- Type: `custom property`
- Group: CSS
The deeper shadow of `floating: "dialog"` glass.
### --liquid-glass-control-shadow
- Type: `custom property`
- Group: CSS
The faint shadow light interactive glass floats on.
### --liquid-glass-tabs-tint
- Type: `custom property`
- Group: CSS
The colour a tabs track's glass is washed with: near-black in dark, a light grey for a light segmented control. Set it on the track.
### --liquid-glass-tabs-fill
- Type: `custom property`
- Group: CSS
The colour of a segmented control's solid thumb (white in light, grey in dark), with --liquid-glass-tabs-fill-alpha. Set it on .liquid-glass-tabs-fill.