Liquid Glass
Glass follows Apple’s Liquid Glass model: it is a functional layer for controls and navigation that floats above content, not a decoration for content. The kit ships the material in every preset, applies it by default only to the chrome layer of the glass preset, and lets any surface opt in.
References: Apple HIG Materials, WWDC25 Meet Liquid Glass and Get to know the new design system.
When to use it
Section titled “When to use it”| Layer | Examples | Default |
|---|---|---|
| Control and navigation | Dialog, Drawer, Popover, menus, HoverCard, CommandPalette, ActionBar, Topbar, Sidebar, tooltips | Glass in the glass preset, solid in the others |
| Content | Card, ItemCardGroup, KpiGroup, ResponsiveList, DataGrid, Kanban, chat bodies | Always solid |
Rules of thumb: put glass over imagery or changing content, keep it off long reading surfaces, and never stack glass on glass.
Variants
Section titled “Variants”- Regular (
glass): adaptive, the legible default. Blur 20px, saturation 1.8. - Clear (
glass-clear): more transparent for media-rich content, so it needs a stronger veil.--glass-clear-dimpushes the backdrop away from the ink colour (white in the light theme, black in the dark one) and, in the dark theme,--glass-clear-brightnessdarkens bright content behind it. Keep both; without them text is not legible over white or black. Regular glass has a lighter veil (--glass-regular-dim) for the same reason.
Both draw a hairline edge, a specular highlight on the top edge and a soft diagonal sheen.
<div class="glass rounded-dialog p-5">Regular</div><div class="glass-clear rounded-dialog p-5">Clear</div>Tokens
Section titled “Tokens”All presets declare the same tokens, so glass is a material and not a preset. Per variant glass-regular-* and glass-clear-*: tint, blur, saturate, filter, border, edge, shadow, dim, fallback. Shared: glass-sheen, glass-noise (a transparent no-op; set it to a tiling url(data:...) image for grain) and glass-nested-tint. The glass preset only tunes them (blur 24px, saturation 1.9). See the token reference. Theme.kt exports UiGlass with the same values for Compose.
The older --surface-alpha, --surface-blur, --surface-filter and --surface-overlay still resolve; they alias the regular material in the glass preset.
Opting in
Section titled “Opting in”Every surface accepts glass, but only on request.
export function OptIn({ items }: { items: ItemCardGroupItem[] }) { return ( <> <Card material="glass-clear">Over a photo</Card> <ItemCardGroup classNames={{ list: "glass" }} items={items} /> </> );}
export function AlwaysOpaque() { return <DialogContent material="solid">Always opaque</DialogContent>;}material?: "solid" | "glass" | "glass-clear"exists on Card, Dialog, Drawer, Popover, DropdownMenu, ContextMenu, HoverCard, CommandPalette, ActionBar, Topbar and Sidebar.- Elsewhere put
glass,glass-clearorsurface-solidinclassNameorclassNames:cntreatssurface-card,surface-overlay,surface-nav,surface-side,surface-solid,glassandglass-clearas one group, so the last one wins. - A different default for a whole family of components is not a built-in switch; override the
--surface-*tokens in a scope.
Accessibility
Section titled “Accessibility”The opaque fill is the base style and the blur only exists inside @supports (backdrop-filter), so unsupported browsers never get a see-through panel. On top of that:
prefers-reduced-transparency: reduce: opaque fill, no blur.prefers-contrast: more: opaque fill plus a strong ring.forced-colors: active:Canvasbackground and aCanvasTextoutline.
Contrast is measured in the test suite on real screenshots, not estimated. For glass, glass-clear, and the glass-preset surface-overlay and surface-nav, both text-ink and text-ink-2 keep 4.5:1 over a solid white, a solid black, a hard half-and-half and a fine-stripe backdrop, in the light and the dark theme. Other text tokens (text-ink-3, tinted text, links) are not covered: check them if you place them on glass. The price of the guarantee is that dark clear glass is visibly dimmed and light clear glass is milky; that is the legibility floor for an arbitrary backdrop, so lower the veil only when you control what sits behind.
In the preset-scoped fallbacks, only the glass preset adds the high-contrast ring and the forced-colors outline; the other presets keep exactly their 0.2.0 overlay, top bar and sidebar fills in every media mode.
Glass on glass
Section titled “Glass on glass”Glass inside .glass, .glass-clear or .surface-overlay drops its blur and takes --glass-nested-tint, a near-solid fill. This covers .glass, .glass-clear, .surface-overlay, .surface-nav and .surface-side ancestors. Menus, popovers and hover cards are portaled to body, so they are not DOM descendants; the popper marks such content data-glass-nested when its trigger sits inside a glass, glass-clear or overlay surface (not a top bar or sidebar), and it then takes the same near-solid fill.
The glass class name
Section titled “The glass class name”The utilities are plain, unprefixed class names (glass, glass-clear, surface-solid, …) generated by your Tailwind build. If your app or another library already defines a .glass class, the two will collide. Options: rename yours, or override the kit’s utility in your own CSS with @utility glass { ... } after importing theme.css. A prefixed alias (for example ui-glass) was considered and not added: the kit’s other utilities are unprefixed too, and a second spelling would double the nesting selectors and the merge group for a collision that can be handled in the consumer’s CSS. Components never need the class: they take material.
Performance
Section titled “Performance”- Blur cost grows with radius times area. Stay between 8 and 24px and avoid full-screen glass.
- Never put glass on repeated list items; use it for a few floating surfaces.
- An active
backdrop-filtercreates a stacking and containing block; do not nest them. - Do not animate the blur radius. Fade opacity or transform instead.
Two looks: frosted and liquid
Section titled “Two looks: frosted and liquid”Every glass has two looks. Frosted (glass, glass-clear) is the 0.2/0.3 material: blur, tint, hairline edge. It renders exactly as before. Liquid (liquid, liquid-clear) follows what makes Apple’s Liquid Glass recognisable:
- Lensing: the backdrop is bent over the whole surface like a dome, strongest at the rim and gentle (never abrupt) towards the centre, with at most 24 px of offset so a large surface does not smear.
--glass-lens-coverage: rim(or thecoverageoption) limits it to a bevel of about 1.2 times the corner radius, with the middle undisplaced. - Specular rim: with the lens, a second map lights a 1.4px glint on the walls that face the upper left (half strength on the opposite walls). The glint is the lensed backdrop pushed to a saturation of 6, so it takes the colour behind the glass; a 0.75px dark contour and a faint inner shadow separate the glass from the backdrop. Without the lens the CSS rim does the same job with inset shadows.
- Thickness: inner highlight, edge glow and a lower inner shadow, plus a soft outer shadow.
- Chromatic dispersion at the rim of small surfaces, a thin blur (2px regular, 0.6px clear), saturation 1.5, a soft halo behind text (
--liquid-halo) and a deeper outer shadow (--liquid-shadow, override it to ease the shadow over plain light content). - Press flex: interactive surfaces (
button,a,[role=button],[data-glass-interactive]) brighten from within and scale on press. Nothing animatesbackdrop-filter, and underprefers-reduced-motionthere is no transition.
<div class="liquid rounded-dialog p-5">Regular liquid</div><button class="liquid-clear rounded-full px-6 py-2">Clear liquid</button>export function LiquidCards() { return ( <> <Card material="liquid">Lensed along the rim</Card> <Card material="glass">Frosted, as before</Card> </> );}material accepts "solid" | "glass" | "glass-clear" | "liquid" | "liquid-clear". The names glass and glass-clear keep meaning frosted, so nothing changes for existing apps.
Configuring the liquid
Section titled “Configuring the liquid”Options go in style, through liquidStyle(options), on any liquid surface: material="liquid" (or "liquid-clear") on a component that has a material prop (Card, Dialog, Drawer, Popover, DropdownMenu, ContextMenu, HoverCard, Topbar, Sidebar, ActionBar, CommandPalette), or a plain element with the liquid class. liquidStyle() is synchronous and returns plain custom properties, so the server markup is already right and hydration never changes it. Every option is optional and maps to one --liquid-* custom property, so the same configuration can come from a prop, from a scope, or from the theme; the lens engine and the CSS read the same variables, and they inherit. Change them at run time and the lens follows.
export function ConfiguredLiquid() { return ( <Card material="liquid" style={liquidStyle({ profile: "dome", frost: 4, refraction: 0.8, specular: { intensity: 1.2, angle: -60 }, shadow: { opacity: 0.8, offsetY: 14 }, interactive: "drag", motion: "jelly", })} > A drop of glass </Card> );}| Option | Variable | Meaning (default) |
|---|---|---|
refraction |
--liquid-refraction |
0 to 1: the bend as a fraction of the strongest offset that cannot fold the backdrop (1) |
bevelWidth |
--liquid-bevel |
px: width of the refracting band (rim) or cap of the offset (full, dome); derived from size and radius, at most 24 px for the whole-surface profiles |
profile |
--liquid-profile |
"full" a squarish dome over the whole surface (default), "dome" a round drop that is flatter in the middle, "rim" only a bevel along the edge |
dispersion |
--liquid-dispersion |
0 to 0.2: colour channels pulled apart (0); small surfaces only |
frost |
--liquid-frost |
px of backdrop blur (2 regular, 0.6 clear) |
saturation, brightness |
--liquid-saturation, --liquid-brightness |
backdrop filter (1.5, 1) |
tint |
--liquid-tint-color, --liquid-tint-amount |
{ color, amount }: a colour laid over the glass (none; amount 0.2) |
rim |
--liquid-rim-strength |
0 to 2: edge highlight (1) |
specular |
--liquid-specular, --liquid-specular-angle |
{ intensity, angle }: the glint on the walls facing the light (1, -120 deg: upper left) |
fresnel |
--liquid-fresnel |
0 to 1: extra glow towards the edge (0) |
grain |
--liquid-grain |
0 to 1: film grain (0) |
radius |
--liquid-radius |
px corner radius (the component’s own) |
opacity |
--liquid-opacity |
0 to 1: the legibility veil (1) |
shadow |
--liquid-shadow-opacity, --liquid-shadow-spread, --liquid-shadow-offset |
{ opacity, spread, offsetY } of the outer shadow (1, 0, 10) |
interactive |
--liquid-interactive |
"press", "drag" or "none"; by default press on buttons, links and data-glass-interactive. The automatic lens reads it only from the surface itself (its style), never from a scope above it |
motion |
--liquid-motion |
"snappy" (default), "smooth", "soft", "jelly" or false (off) |
LiquidGlassOptions, LIQUID_DEFAULTS, LIQUID_PRESETS, liquidStyle() and liquidVars() are exported from @jcsoftdev/ui/liquid (and, for the imperative engine, from @jcsoftdev/ui/glass); mergeLiquid() and the presets LIQUID_PRESET_CLEAR, LIQUID_PRESET_REGULAR, LIQUID_PRESET_CRYSTAL and LIQUID_PRESET_THICK are too. liquidStyle(options) is the style object for any surface; liquidStyle(mergeLiquid(LIQUID_PRESET_THICK, { radius: 24 })) starts from a preset. The values are clamped to ranges the lens can honour.
Presets. data-glass="liquid-clear", "liquid-regular" and "liquid-thick" switch the glass surfaces of a scope to the liquid look (like data-glass="liquid") and set a bundle of the options above (base.css carries them; LIQUID_PRESETS is the same data). Theme defaults are plain CSS: :root { --liquid-frost: 3px; --liquid-profile: dome; } changes every liquid surface that does not set its own.
Contrast. The guarantees listed under Tokens hold for the defaults. A lower opacity, a tint, grain or a thinner frost can leave text without them; check the copy you put on a configured surface.
Profiles and thickness. Both whole-surface profiles pull each pixel towards the centre along the gradient of a radial coordinate (full: exponent 3, pull A t^1.5; dome: exponent 2, pull A t^2, with t the radial coordinate over its value at the corners), so neither has a crease along the diagonals, and both are tested not to fold. refraction and bevelWidth scale the filter, not the map, so changing them does not re-rasterise it. A bigger surface is thicker: its shadow grows with the size (--liquid-size, 0.7 to 1.5 times), and the lens offset grows with it up to the cap.
Crystal: a thick, clear lens
Section titled “Crystal: a thick, clear lens”profile: "crystal" (and the LIQUID_PRESET_CRYSTAL preset, data-glass="liquid-crystal") is the look of a thick convex piece of acrylic: the backdrop is minified (the surface shows a wider area than it covers, squeezed in), bent hard towards the rim, split into colour fringes at the edge, with no frost, no veil and no tint, only a thin bright edge.
export function Crystal() { return ( <Card material="liquid" style={liquidStyle(LIQUID_PRESET_CRYSTAL)} className="rounded-full px-8 py-4" > <span className="font-bold text-white [text-shadow:0_2px_10px_rgb(0_0_0/.5)]"> Liquid Glass </span> </Card> );}refractiongoes from 0 to 2 for this profile (the other profiles treat anything above 1 as 1). A minifying mapp -> p (1 + k s(t))has a positive Jacobian for everyk, so it cannot fold; the offset is capped at 260 px to bound the work.- Minification samples outside the surface, which a filter on the surface cannot see. The engine therefore puts the filter on a layer inside the surface that extends past it by the largest offset and is clipped back to the silhouette (
data-glass-lens-layer), and moves the surface’s background and inset rim under/over it. Give the surface child elements (aspanaround the label), not a lone text node, and keepoverflowvisible on it. - Inside a clipping ancestor (
overflowother than visible,clip-path,contain: paint) the layer only extends as far as the clip allows. A side with less room than the largest offset bends less, eased across the surface, so the lens is flatter towards that edge instead of sampling backdrop that is not there. - There is no contrast guarantee: use short, bold labels with a text shadow, and nothing small. The surface uses the same budgets (8 lenses, area) as the others; the filter region of one crystal is larger than its box.
Motion
Section titled “Motion”The lens engine drives its own springs (a damped spring written for the kit, no dependency) and a single requestAnimationFrame loop that sleeps when everything is at rest:
- Materialise: when a lens first appears its strength springs from 0 to its target instead of fading.
- Press (
interactive: "press", the default for buttons and links): the lens deepens by 35%, the surface swells by about 16 px over its width, a touch glow (a radial highlight at 15% opacity) follows the pointer and the rim light turns towards it. - Drag (
interactive: "drag"): the surface follows the pointer through a rubber band (at most 48 px), stretches along its speed and squashes across it by at most 20% with two deliberately mismatched springs, and settles back like jelly when released. A drag starts only on the surface itself (a press on a button, link or field inside it stays the child’s) and takes the pointer once it has travelled 6 px. The click that ends a drag is swallowed (within about 350 ms, and only that gesture’s). The motion is written to the individualtranslateandscaleproperties, composed with your own, so an authortransformis never touched and yourtranslateandscaleare put back at rest. motionpicks the spring (snappy,smooth,soft,jelly);motion: false(or--liquid-motion: off) turns all of it off and shows the lens at full strength at once.
Everything is off under prefers-reduced-motion (the lens is removed with it, as before) and wherever there is no lens (Safari, Firefox): the press flex of the CSS stays.
Switching the whole app
Section titled “Switching the whole app”Put data-glass="liquid" on <html> (or on any scope): glass, glass-clear and, in the glass preset, the chrome surfaces (surface-overlay, surface-nav, surface-side: Dialog, Drawer, menus, Topbar, Sidebar) render liquid. The content layer (surface-card) stays solid. Remove the attribute to go back to frosted. Limits: the switch is one-way down the tree (a nested data-glass="frosted" does not undo an outer liquid; use material on the component instead). With <LiquidGlass /> mounted, flipping the attribute takes or gives the lens at once.
The lens
Section titled “The lens”Chromium only: it is the one engine that paints backdrop-filter: url(#filter). The lens is opt-in: render <LiquidGlass /> (from @jcsoftdev/ui/liquid) once near the root of an app that uses Liquid Glass (or call startLiquidGlass(), which returns its stop). It watches the page and gives the lens to every element whose computed style resolves to a lensed liquid (the --glass-lens flag that the liquid utilities set), and a backdrop filter (an element that only inherits the flag is not a surface). It considers elements whose class names contain liquid, glass or a chrome surface (surface-overlay, surface-nav, surface-side), or whose inline style sets --glass-lens; mark a custom class built with @apply liquid with data-glass-lens="on". It imports the engine as a separate chunk the first time one does (a failed fetch is retried with a backoff); the crystal and dispersion parts are further chunks, fetched only for surfaces that use them. Without it, liquid surfaces keep the CSS look (rim, depth, blur), and no lens code is in the bundle or fetched at run time. Frosted and solid components carry no liquid code at all. It does nothing on the server, does nothing and loads nothing where the lens cannot paint, and releases a surface’s filter as soon as the surface leaves the page. A lens applied by code (applyGlassRefraction, useGlassRefraction) on the same element keeps its own options and is never stopped by the watcher; after its cleanup the automatic lens takes over again.
What the watcher follows, so a variant that flips the flag takes or gives the lens:
- elements added to and removed from the page;
- changes of
class, of the inline--glass-lens, ofopenand of anydata-*oraria-*attribute, on the surface or an ancestor (data-[state=open]:liquid,aria-expanded:liquid,group-data-[...]:liquid,data-glass,data-theme); - the window size, once a resize settles (breakpoints such as
md:liquid); - the size of the surface and of its nearest size container (container queries);
- the color scheme, reduced transparency, more contrast, forced colors and reduced motion preferences.
Not followed: pseudo-classes (hover:liquid, focus:liquid, focus-within:liquid) and sibling variants (peer-*). For those, keep the surface liquid in every state, or lens it yourself with useGlassRefraction() or applyGlassRefraction() from @jcsoftdev/ui/glass (data-glass-lens="on" only makes the watcher consider an element; it does not follow a pseudo-class).
The map is rasterised from the signed distance function of the rounded rectangle (size, radius and pixel ratio aware). Across a rim, the displacement follows a convex squircle height profile through Snell’s law (n = 1.5): zero in the middle, strongest at the edge, pulling the content from just inside the shape. Full coverage is a dome over a superellipse radial coordinate (r = (|u|^3 + |v|^3)^(1/3), u and v the position over the half size): each pixel is pulled towards the centre along the gradient of r, by A (r / r_corner)^1.5. The gradient is continuous everywhere, so there is no crease along the diagonals (a field built from the distance to the nearest edge, or from each axis alone, makes the glass read as a four-faced pyramid) and no seam at the centre; A is at most 0.55 of the smaller half size and 24 px. The offset at the silhouette (up to about 17 px) makes a backdrop shape that crosses the edge jump there, as in real glass. The first lenses (a Snell-law profile, strength equal to the bevel width) folded back on themselves where the offset fell faster than 1 px per px, which broke straight stripes into a repeating sawtooth along the walls; both profiles now keep the slope under 1, and a test checks it. The veil is adaptive: the lens is blurred wide, its luminance read, and a veil of the ink-contrasting colour is laid only where the backdrop is close to the ink, so over friendly backdrops the body stays clear. The directional specular layers are added on top. Dispersion is off by default (it paints a colour fringe on hard edges); turn it on with --glass-lens-dispersion: .05 or the option, on surfaces up to 100,000 px squared, where it is naturally strongest at the rim because it scales with the local offset. When a lens is first applied the refraction springs up from nothing (the materialise), and on a pressed control it lifts to 1.35 times for the duration of the press (see Motion); neither runs under reduced motion, where there is no lens.
Budgets: at most 8 lenses alive at once (hidden, zero-area and far-offscreen surfaces do not take a slot, and get one once they show), no lens on a surface over 640,000 px squared (they keep the plain liquid look), dispersion only up to 100,000 px squared, the map is rebuilt at most about ten times a second while a surface resizes (one coalesced pass per frame for all surfaces), and the last 16 maps (displacement and specular) are cached. Opt out with data-glass-lens="off" on the element or any ancestor, or --glass-lens: 0 in CSS. It follows prefers-reduced-transparency, prefers-contrast: more, forced-colors and prefers-reduced-motion live.
Other engines (Safari, Firefox) get the base liquid look without refraction: rim, thickness, saturation, outer shadow. Legibility is the same: the uniform CSS veil is used there. With the lens active the veil is lighter only along the rim (at most the outer 28px), so text placed on the rim of a surface over a black or white backdrop has less contrast than the guarantee below, which is measured away from it.
Measured cost
Section titled “Measured cost”Chromium headless with software compositing, device pixel ratio 2, unthrottled frame loop while a photo scrolls under the surface (so every frame re-renders the backdrop). These are relative numbers, not GPU numbers:
| Surface | Frame time |
|---|---|
| No lens (frosted or liquid) | 0.1 ms |
| Nav bar 1280x56, lens + specular (dispersion off, the default) | about 6 ms |
| Dialog 520x320, lens + specular (dispersion off, the default) | about 16 ms |
| Either with dispersion on | about 2x |
The cost only exists while the backdrop changes (scrolling under a nav, animations behind a surface); a modal dialog over a static page does not pay it per frame. Dispersion roughly doubles it on large surfaces, which is why it is off by default there. Building a map costs 1 to 7 ms at ratio 2 for typical sizes (a 520x320 surface: about 6 ms).
Imperative API
Section titled “Imperative API”applyGlassRefraction(element, options) and useGlassRefraction() still exist for any element, glass or not, with scale, bezel, dispersion, veil, specular, animate, refraction: "force" and auto (only while --glass-lens is 1). For the automatic lens, mount <LiquidGlass /> once:
export function App({ children }: { children: ReactNode }) { return ( <> <LiquidGlass /> {children} </> );}Safari parses backdrop-filter: url(...) and then paints nothing, and Firefox ignores it, so @supports cannot gate it. supportsBackdropRefraction() is a feature allow-list: the declaration parses, SVGFEDisplacementMapElement exists and the Blink-only -webkit-app-region property exists. It is not a pixel test.
Tokens (liquid)
Section titled “Tokens (liquid)”Shared, per theme where light and dark differ: liquid-regular-blur, liquid-clear-blur, liquid-saturate, liquid-regular-filter, liquid-clear-filter, liquid-regular-tint, liquid-regular-dim, liquid-clear-tint, liquid-clear-dim, liquid-rim, liquid-depth, liquid-depth-active, liquid-halo, liquid-shadow, and the ones a configured surface scales: liquid-rim-lit, liquid-rim-back, liquid-rim-ring, liquid-shadow-ink, liquid-shadow-alpha. The two *-filter tokens are a reference: the utilities compose the same filter from the blur and saturate tokens and the per-surface options. Border, edge-less shadow and fallback reuse glass-*. Liquid glass trades veil for transparency, so its measured floors are lower than the frosted 4.8:1 and documented. Without the lens (the fallback, a uniform veil): regular keeps text-ink at 4.5:1 and text-ink-2 at 3.5:1, clear keeps text-ink at 4.5:1 and text-ink-2 at 2:1 over white, black, split and striped backdrops. With the lens (adaptive veil), over a smooth photo-like backdrop text-ink keeps 4.5:1 and text-ink-2 2:1 on every pixel; over hard fine stripes the worst pixel keeps 2:1 for text-ink and the text halo carries the glyphs. Use regular glass, or frosted, for small secondary copy. The halo is not counted in these numbers. The price is the same as for frosted: over a dark backdrop, light liquid glass is pale.
Sources
Section titled “Sources”- WWDC25 Meet Liquid Glass: lensing (“bends, shapes, and concentrates light”), specular highlights that respond to geometry, tint adapting to what is behind, Regular vs Clear, glass that “illuminates from within” on press, thicker glass with deeper shadows and stronger lensing.
- Apple Newsroom, new software design: “reflects and refracts its surroundings”, specular highlights.
- liquidglass.dev (gentpan/liquidglass, MIT): ideas for the rim-band bevel, the spring constants of the press, follow and settle motion and the velocity stretch; the code here is our own. The parameter set of the options mirrors what ybouane’s liquid-glass playground exposes (names only, no code or constants).
- Apple HIG Materials is JavaScript-rendered and could not be read automatically.
- kube.io, Liquid Glass in the Browser: Snell’s law, squircle height profile, displacement map encoding, Chromium-only
backdrop-filter: url(). Dispersion by per-channel displacement is a common technique that is not described there; the implementation here is our own.
Crystal caveat: in Chromium an ancestor with
overflow: hiddenplusborder-radiusclips the lens layer to a rectangle. Clip the ancestor withclip-path: inset(0 round <radius>)instead.