# @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 and tabs whose thumb lifts into a lens. MIT.

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.

## Quick start

```sh
npm install @colineapp/liquid-glass
```

```tsx
import { LiquidGlass } from "@colineapp/liquid-glass/react";
import "@colineapp/liquid-glass/styles.css";

<nav className="relative rounded-full px-6 py-3">
  <LiquidGlass appearance="class" />
  …
</nav>
```

## 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 surface, fade and zoom its contents, never the surface itself, and call materialize() and dematerialize() for the glass.
- Controls on glass get no glass of their own: give the selected or hovered one the liquid-glass-well class.
- Rim refraction renders in Chromium. Safari and Firefox get the same material, light and motion without the bend.

## Glass options

Options for `<LiquidGlass />`, `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 `<html>` has the `dark` class (Tailwind's class strategy, next-themes). `dark` and `light` fix it.

```tsx
<LiquidGlass appearance="class" />
```

### variant

- Type: `"regular" | "clear" | "lens"`
- Default: `"regular"`
- Group: Glass options

`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`.

```tsx
<LiquidGlass variant="clear" />
```

### 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
<LiquidGlass floating="dialog" />
```

### 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, and a press blooms out from there.

```tsx
<LiquidGlass interactive />
```

### swell

- Type: `boolean`
- Default: `false`
- Group: Glass options

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.

```tsx
<LiquidGlass interactive swell />
```

### 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 (50% on dark regular glass, 40% on light). Any CSS colour, including `var()`.

```tsx
<LiquidGlass tint="var(--background)" />
```

### 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
<LiquidGlass hue="#3d6bff" />
```

### 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
<LiquidGlass hue="#ff4fa3" hueStrength={0.7} />
```

### geometry

- Type: `Partial<GlassGeometry>`
- 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
<LiquidGlass geometry={{ maxFall: 0.5, edge: 1 }} />
```

### tone

- Type: `{ dark?: Partial<GlassTone>; light?: Partial<GlassTone> }`
- Group: Glass options

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`.

```tsx
<LiquidGlass tone={{ dark: { brightness: 0.4 }, light: { tint: 0.5 } }} />
```

### 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
<LiquidGlass fit="scroll" floating="dialog" />
```

### 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
<LiquidGlass refraction={false} />
```

### samples

- Type: `1 | 4`
- Default: `1`
- 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, so keep it for small glass with nothing blurring its backdrop, like a lens.

```tsx
<LiquidGlass variant="lens" samples={4} />
```

## 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.8`
- 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), 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.

### 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.5`
- Group: Geometry fields

Bottom glow strength relative to the top.

## The glass handle

`createLiquidGlass()` and `attachLiquidGlass()` return it; `useLiquidGlass()` returns a ref to it, and `<LiquidGlass glassRef={ref} />` 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<void>`
- Group: The glass handle

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.

```ts
await glass.materialize({ duration: 240, scale: 0.96 });
```

### dematerialize(timing?)

- Type: `(timing?) => Promise<void>`
- Group: The glass handle

Fades the glass back to clear and holds it there, for surfaces that leave. Wells on it ease back with it.

```ts
await glass.dematerialize({ duration: 160 });
```

### 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. 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() }`.

### 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.

### <LiquidGlass />

- 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
<nav className="relative rounded-full px-6 py-3">
  <LiquidGlass interactive />
  …
</nav>
```

### useLiquidGlass(ref, options)

- Type: `RefObject<LiquidGlass | null>`
- 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<LiquidGlassTabs | null>`
- Group: React

Liquid Glass tabs on a track you render. See Tabs.

## 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
<button className="liquid-glass-well rounded-full">Today</button>
```

### --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.

# Examples

## A glass toolbar in React

Render <LiquidGlass /> first inside a positioned, rounded element.

```tsx
import { LiquidGlass } from "@colineapp/liquid-glass/react";
import "@colineapp/liquid-glass/styles.css";

export function Toolbar() {
  return (
    <nav className="relative flex gap-1 rounded-full p-1.5">
      <LiquidGlass appearance="class" interactive />
      <button className="liquid-glass-well rounded-full px-4 py-2">Today</button>
      <button className="rounded-full px-4 py-2">Week</button>
    </nav>
  );
}
```

## Without React

createLiquidGlass inserts the glass as the element's first child.

```ts
import { createLiquidGlass } from "@colineapp/liquid-glass";
import "@colineapp/liquid-glass/styles.css";

const glass = createLiquidGlass(document.querySelector(".toolbar")!, {
  interactive: true,
});

glass.update({ variant: "clear" });
glass.destroy();
```

## Pill tabs

Your tabs, your selection; the thumb follows aria-selected.

```tsx
import { useRef } from "react";
import { useLiquidGlassTabs } from "@colineapp/liquid-glass/react";

export function Period({ value, onChange }: { value: string; onChange: (value: string) => void }) {
  const track = useRef<HTMLDivElement>(null);
  useLiquidGlassTabs(track, { appearance: "class" });
  return (
    <div ref={track} role="tablist" className="flex rounded-full p-1">
      {["Day", "Week", "Month"].map((label) => (
        <button key={label} role="tab" aria-selected={label === value} onClick={() => onChange(label)}
          className="rounded-full px-4 py-1.5 text-sm font-medium">
          {label}
        </button>
      ))}
    </div>
  );
}
```

## A menu that materializes

Fade the contents, never the surface; the glass grows from clear.

```tsx
import { useEffect, useRef, useState } from "react";
import { LiquidGlass, type LiquidGlassHandle } from "@colineapp/liquid-glass/react";

export function Menu({ open, items }: { open: boolean; items: string[] }) {
  const glass = useRef<LiquidGlassHandle | null>(null);
  const [active, setActive] = useState(-1);
  useEffect(() => {
    if (open) void glass.current?.materialize({ duration: 240, scale: 0.96 });
  }, [open]);
  if (!open) return null;
  return (
    <div role="menu" className="absolute right-0 top-12 w-56 rounded-2xl p-1.5">
      <LiquidGlass glassRef={glass} appearance="class" floating />
      {items.map((item, index) => (
        <button key={item} role="menuitem" onPointerEnter={() => setActive(index)}
          className={`w-full rounded-xl px-3 py-2 text-left ${index === active ? "liquid-glass-well" : ""}`}>
          {item}
        </button>
      ))}
    </div>
  );
}
```

## A glass dialog

floating="dialog" casts the deeper shadow; the glass materializes as the contents zoom in.

```tsx
import { useLayoutEffect, useRef } from "react";
import { LiquidGlass, type LiquidGlassHandle } from "@colineapp/liquid-glass/react";

export function ShareDialog({ onClose }: { onClose: () => void }) {
  const glass = useRef<LiquidGlassHandle | null>(null);
  useLayoutEffect(() => {
    void glass.current?.materialize({ duration: 280, scale: 0.95 });
  }, []);
  const close = () => void glass.current?.dematerialize({ duration: 180, scale: 0.97 }).then(onClose);
  return (
    <div role="dialog" aria-modal="true" className="fixed left-1/2 top-1/3 w-80 -translate-x-1/2 rounded-[28px] p-5">
      <LiquidGlass glassRef={glass} appearance="class" floating="dialog" />
      <h2 className="font-semibold">Share this album</h2>
      <button className="liquid-glass-well mt-4 h-10 w-full rounded-full" onClick={close}>Copy link</button>
    </div>
  );
}
```

## A search field that swells

interactive lights it under the pointer; swell springs it on press.

```tsx
<label className="relative flex h-11 items-center gap-2 rounded-full px-4">
  <LiquidGlass appearance="class" interactive swell />
  <SearchIcon />
  <input className="bg-transparent outline-none" placeholder="Search" />
</label>
```

## A magnifying lens

The tab lens on its own: the lens variant, its geometry, four samples and magnify().

```tsx
import { useRef } from "react";
import { LENS_GEOMETRY } from "@colineapp/liquid-glass";
import { LiquidGlass, type LiquidGlassHandle } from "@colineapp/liquid-glass/react";

export function Lens() {
  const lens = useRef<LiquidGlassHandle | null>(null);
  return (
    <div
      className="absolute size-24 rounded-full"
      onPointerDown={() => lens.current?.magnify(1.15)}
      onPointerUp={() => lens.current?.magnify(1)}
    >
      <LiquidGlass glassRef={lens} appearance="class" variant="lens" geometry={LENS_GEOMETRY} samples={4} />
    </div>
  );
}
```
