Web Liquid Glass
A production glass material for websites, extracted from a live Next.js + Tailwind site. The full working source is in reference/:
reference/liquid-glass.css: the whole material (tokens, surface, variants, shaped glass, refraction, degradation). About 2,200 lines with long comments explaining every value. Read the section you need rather than the whole file.reference/glass.tsx: the<Glass>React component and the SVG refraction filter.reference/glass-specular.tsx: the pointer- and tilt-tracked specular highlight.
The reference imports two site helpers (cn and LITE_CLASS from a lite-mode module). Swap in the project's own cn/clsx, and a class name for "effects off". Rules named sky-*, kiosk-* and prayer-related selectors are site-specific examples of variants. Drop them or use them as patterns.
What glass is
Glass has four parts. A web version looks cheap when it ships only the first:
- Material: blur AND a saturation boost (about 200%). Below ~150% it reads as grey plastic. Add brightness lift and contrast pull so busy content behind it diffuses to a soft wash.
- Rim: a bright hairline where light catches the edge, dimmer on the opposite edge. Blur without a rim never reads as glass.
- Sheen: a soft specular highlight inside the top edge.
- Depth: a wide, very soft, low-opacity shadow, not a tight card shadow.
Refraction (content bending at the edge) is a fifth part, and the only one the web can't do cheaply. It needs backdrop-filter: url(#svg-filter), which works in Chromium only.
Where glass goes
Put glass on the navigation layer (bars, capsule navs, floating controls, sheets), not on body content. Text contrast on glass depends on whatever scrolls behind it, so it can't be guaranteed. Keep opaque cards the default for content and make glass opt-in per surface (.lg).
Rules that cost real time
- Glass CSS must live in the same file as
@tailwind base, inside@layer. Tailwind resolves@layerper file, so a separate CSS file errors on@layer base. Dropping the wrapper makes the rules unlayered, and unlayered rules beat every Tailwind utility..lg { position: relative }then silently overridesfixedon a nav. - Never write
--x: calc(var(--x) * 0.7). It's a cycle: invalid at computed-value time, it silently falls back to the inherited value with no error. Use a second variable (--lg-clarity-bump,--lg-press). - A glass surface has exactly one edge, and
::beforedraws it. Two edges (a::beforerim plus insetbox-shadowbevels, or a spread shadow outside the border box) give a "double border" that no amount of alpha tuning fixes. corner-shapeis not inherited. Withcorner-shape: squircleon the surface, every pseudo-element drawing on the boundary needs BOTHborder-radius: inheritandcorner-shape: inherit, or the rim follows a different curve from the body.- No
backdrop-filteron glass nested inside glass. It samples an already-tinted backdrop and goes murky. A selection chip inside a glass bar (.lg-chip) is a tinted lens with no blur of its own. - Don't animate an inherited custom property on
:root. It invalidates the whole document's style: about 11.6 ms per frame on a 60-surface page, against 2 ms writing the same value per element. - Bars re-blur on every scroll frame. Give
.lg-bara smaller blur radius pluscontain: paint. If a device still janks, set--lg-blur-bar: 0px; the rim and sheen carry most of the look. - Don't flatten frosted glass to fix a glitch. A near-opaque "solid colour" bar loses the look. Find the geometric cause instead (masks thinning the body at the edge, a doubled rim).
- An SVG refraction lens must never sample past its own box. Chrome returns transparent there and the original content shows through as ghosts.
Safari and iPhone
WebKit supports backdrop-filter but rejects SVG filters inside it, so Safari gets less than Chrome: no refraction, ever. What Safari can refract is its own toolbar. Set viewportFit: "cover" so the page runs under it, then env(safe-area-inset-*) becomes mandatory on the nav and footer.
Accessibility and fallbacks (always ship these)
Every surface must degrade to a near-opaque tint that looks close and costs nothing per frame:
@media (prefers-reduced-transparency: reduce)@supports not (backdrop-filter: blur(1px))@media (prefers-contrast: more): a stronger tint and a solid edge- a root class such as
.lg-offthat kills every glass surface at once (for a user toggle or a low-power mode)
Give bars their own, higher maximum tint (--lg-tint-max-bar) so nav links keep text contrast when content scrolls under them.
How to apply
- Copy the token block and the surface section from
reference/liquid-glass.cssinto the project's main CSS file, inside its existing@layerblocks (rule 1). - Map the colour tokens to the project's palette. The reference uses shadcn-style
hsl(var(--card))tokens. - Use
<Glass>fromreference/glass.tsxor plain.lg,.lg-bar,.lg-nav,.lg-pane,.lg-chipand.lg-refractclasses. - Add the specular highlight from
reference/glass-specular.tsxonly where it earns its cost (hero controls, the main nav), and respectprefers-reduced-motion. - Check in Chrome AND Safari, in light AND dark, over a busy photo and over plain text.
Ready-made controls: Glisse
Glisse by Kelvin Zhang (npm install glisse, MIT, React 18+, no other dependencies) ships interactive Liquid Glass controls with the press-to-lift lens behaviour already built: Button, Switch, Slider, TabBar, SegmentedControl, Menu, Input, SearchField and a plain Glass surface. Its styles are injected by the JS bundle, so there's no CSS file to import.
When to use which:
- Glisse for controls: switches, sliders, tab bars and segmented controls whose thumb lifts into a lens while dragged. Building that by hand is days of work.
- This material for surfaces: nav bars, capsule navs, panels, sheets, cards over photos, and anything that has to match an existing design system or Tailwind theme.
Traps when combining them:
- Both use the
lg-prefix. Glisse defines.lg-glass,.lg-button,.lg-tab-barand the variables--lg-blur,--lg-tint,--lg-rimand--lg-filter, which collide with this material's tokens. On a page that uses both, rename this material's prefix (for example toylg-) or scope Glisse inside a wrapper that resets those variables. Otherwise each silently retunes the other. - Glisse's dark mode keys off
data-theme="dark"orprefers-color-scheme, not Tailwind's.darkclass. With class-based dark mode, setdata-themeon<html>alongside.dark, or Glisse stays light. - Glisse handles
prefers-reduced-motionbut notprefers-reduced-transparencyorprefers-contrast. Add the fallbacks from this skill's accessibility section around Glisse controls. - Glisse's
.lg-glassalready draws an edge with an insetbox-shadow. Don't add a::beforerim on top of it (rule 3: one edge only).