@vepando/liquid-glass-react
v1.2.1
Published
Apple-style Liquid Glass for React: physically-based refraction (Snell's law), specular rim and touch-reactive glass via SVG backdrop-filter. No WebGL.
Maintainers
Readme
Most "liquid glass" CSS on the web is backdrop-filter: blur() in a
rounded box. Real Liquid Glass bends light: the centre stays clear,
while the curved rim magnifies and stretches whatever is behind it and
catches a bright edge highlight.
This library models that rim as an actual lens. It traces light through
it with Snell's law, writes the result into an SVG displacement map
sized to each element, and lets the browser apply it in its normal
backdrop-filter pass.
npm install @vepando/liquid-glass-reactimport "@vepando/liquid-glass-react/style.css"
import { LiquidGlass } from "@vepando/liquid-glass-react"
<LiquidGlass radius={999} interactive style={{ height: 64 }}>
<nav>…</nav>
</LiquidGlass>Contents
- Features
- Install
- Usage
- UI components
- Props
- Building your own components
- How it works
- Browser support
- Gotchas
- Static / zero-JS variant
- Demo
- Before and after
- Credits
Features
- Physically based refraction. The rim is a convex squircle bezel. Rays are refracted at n = 1.5 and traced down to the content beneath. The centre stays undistorted and the rim magnifies.
- Pixel-exact maps per element. Each map is generated for the element's measured size and regenerated on resize, so the bend sits right on the curve whatever the shape.
- Clear rim, tinted centre. The tint fades out across the bezel, so the refracted backdrop shows bright at the edge, as in Apple's glass.
- Angle-dependent specular. A crisp edge line plus a soft inner glow that follows a configurable light direction.
- Chromatic dispersion. R, G and B bend by slightly different amounts for a faint colour fringe on high-contrast edges.
- Touch interaction.
interactiveadds a springy press-to-grow and a glow from the touch point. - Light and dark tints, or any CSS colour.
- SSR safe. A frosted fallback renders on the server and upgrades on mount, without hydration mismatches.
- Cached maps. Identical controls share maps. New sizes and optical settings generate maps synchronously; large panels and continuous resizing can be expensive. SVG backdrop rendering still costs work every frame, especially with dispersion. Many-element rendering has not been benchmarked across browsers and devices.
- Zero dependencies. React 18+ is the only peer dependency. ESM + CJS + TypeScript types, about 5 KB gzipped.
Install
npm install @vepando/liquid-glass-react
# or: pnpm add @vepando/liquid-glass-react · yarn add @vepando/liquid-glass-react · bun add @vepando/liquid-glass-reactImport the stylesheet once, e.g. in your root layout or entry file:
import "@vepando/liquid-glass-react/style.css"Then use the component anywhere:
import { LiquidGlass } from "@vepando/liquid-glass-react"Next.js App Router: the bundle is already marked "use client", so
you can render <LiquidGlass> straight from a server component. Put the
CSS import in app/layout.tsx. Tested with next build and React 18/19
SSR.
Prefer to own the code? The source is in src/.
The copy-paste version in
skills/liquid-glass/assets/ works without
the package.
Usage
Floating navbar
<LiquidGlass radius={999} className="h-16 w-full max-w-3xl">
<nav className="h-full flex items-center justify-between px-6">…</nav>
</LiquidGlass>Round icon button
<LiquidGlass radius={999} interactive className="h-16 w-16">
<button type="button" aria-label="Search" onClick={openSearch}
className="h-full w-full rounded-full border-0 bg-transparent cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<SearchIcon aria-hidden="true" />
</button>
</LiquidGlass>interactive adds a pointer animation to a div; it does not turn it
into a button. Put a native button or link inside for keyboard access,
activation and focus. The utility classes in these examples assume
Tailwind; equivalent CSS works too.
Dark sheet / panel
<LiquidGlass radius={56} tint="dark" className="p-12">
<p className="text-white text-4xl">It'll be fantastic weather…</p>
</LiquidGlass>Glass on glass (e.g. a tab bar selection droplet). Render the inner glass as a sibling positioned over the outer one, not as a child (see Gotchas):
<div className="relative">
<LiquidGlass radius={999} className="h-16">{tabs}</LiquidGlass>
<div className="absolute inset-1 pointer-events-none">
<LiquidGlass radius={999} blur={0} tint="rgba(255,255,255,.05)"
style={{ width: "25%", height: "100%", transform: `translateX(${active * 100}%)` }} />
</div>
</div>A full working version, including the stretch while it slides, is in
demo/src/Scenes.tsx.
UI components
Ready-made, themeable components for real apps: Button, Tabs, Dialog and Card. They are built on Base UI for keyboard and screen reader behaviour, and installed as source code through the shadcn CLI, so they live in your project and you (or your coding agent) can change them.
npx shadcn@latest add https://leonardseo.github.io/liquid-glass-react/r/glass-button.json
npx shadcn@latest add https://leonardseo.github.io/liquid-glass-react/r/glass-tabs.json
npx shadcn@latest add https://leonardseo.github.io/liquid-glass-react/r/glass-dialog.json
npx shadcn@latest add https://leonardseo.github.io/liquid-glass-react/r/glass-card.jsonThis needs a project set up for shadcn with Tailwind CSS v4. The tokens
come along in components/ui/glass-tokens.css: change the variables there
to theme everything, including dark mode through a .dark class or
data-theme="dark" on <html>.
import { GlassButton } from "@/components/ui/glass-button"
<GlassButton variant="primary">Save</GlassButton>
<GlassButton loading>Saving</GlassButton>GlassTabBar is an iOS-style tab bar or navigation: a quiet pill at rest, and
a magnifying lens you can drag between items. Give items an href for a
navigation; without one they become tabs with arrow-key support.
<GlassTabBar aria-label="Sections" value={tab} onValueChange={setTab} items={items} />Materials follow Apple's glass styles:
| Material | Used for | Character |
| --- | --- | --- |
| regular | Default for every component | Frosted and bright, for controls and surfaces with text |
| clear | Glass over rich media | See-through, with a refracting rim. Check that text on it stays legible |
One layer of glass per stack. A backdrop-filter hides the page from
everything inside it, so glass nested in glass has nothing to refract.
GlassCard and GlassDialogContent handle this for you: a GlassButton
inside them switches to inset glass, which keeps the rim and tint but has no
backdrop of its own. For the same reason the track of GlassTabsList is not
glass, so the indicator moving over it can refract.
Performance. Maps are built once per distinct size and shared, so a page of uniform controls is cheap. Fifty different sizes at once take noticeably longer on slow devices (about 225 ms under 4× CPU throttling, in one block on first render).
Props
Set the size with CSS and choose from the same options Apple's glass offers:
| Prop | Default | Description |
| --- | --- | --- |
| variant | "regular" | Glass style: "regular" (frosted, matched against macOS's NSGlassEffectView), "clear" (see-through, bends what is behind it) or "identity" (no glass). Add a dark class or data-theme="dark" on the glass or an ancestor for dark mode. |
| radius | 32 | Corner radius in px. 999 for pills and circles. "auto" reads the CSS border-radius, so rem and tokens work. |
| tint | none | Any CSS colour, ideally translucent. |
| interactive | false | Press-to-grow and touch glow, for controls. |
| render | - | Render as another element, e.g. render={<button type="button" />}. |
Every other div prop (className, style, onClick, role, …) is
passed through, and ref reaches the element.
Building your own components
useLiquidGlass gives you the engine without the div, for use with Base UI,
Radix or your own markup. It takes the same options as the props above, with
radius defaulting to "auto".
import { Button } from "@base-ui/react/button"
import { useLiquidGlass } from "@vepando/liquid-glass-react"
function GlassButton({ children }) {
const glass = useLiquidGlass()
return (
<Button ref={glass.ref} {...glass.rootProps} className={`${glass.rootProps.className} rounded-full`}>
{glass.wrap(children)}
</Button>
)
}ref must reach the DOM element, and wrap must be its only child. If you
pass your own className, style or pointer handlers, merge them with
rootProps (Base UI's mergeProps does this).
Responsive layout and geometry
Width and height can use rem, %, vw, grid or flex layouts. The
component measures the border box in CSS pixels and regenerates its maps
when that box changes, including padding and borders. CSS pixels are the
coordinate system of the generated filter, not physical device pixels.
<LiquidGlass radius={999} style={{ width: "100%", maxWidth: "40rem", height: "4rem" }}>
<nav>…</nav>
</LiquidGlass>How it works
flowchart LR
A[ResizeObserver<br/>element size] --> B[Snell's law over<br/>squircle bezel]
B --> C[Displacement map<br/>R = x, G = y]
A --> D[Specular rim map]
A --> E[Tint mask]
C --> F["SVG filter<br/>feImage → feDisplacementMap ×3"]
F --> G["backdrop-filter:<br/>blur() url(#filter) saturate()"]
D --> H[plus-lighter overlay]
E --> I[masked tint layer]- Optics. For each distance across the bezel, the surface slope of the squircle profile gives a normal. A straight-down view ray is refracted into the glass and followed to the content plane. The sideways offset is the displacement: 0 on the flat plateau, large near the edge.
- Map. That 1-D curve is swept around the rounded-rect outline along
the inward normal and encoded into R/G, where 128 means no shift.
feDisplacementMapthen samples the backdrop from further inside the glass near the rim, which reads as magnification. - Compositing. The filter is referenced from
backdrop-filteron the component root. A masked tint, the specular map (mix-blend-mode: plus-lighter) and your content sit on top.
The deep dive is in
skills/liquid-glass/references/theory.md.
Browser support
| Engine | Result | | --- | --- | | Blink: Chrome, Edge, Arc, Brave, Opera | Full effect: refraction, dispersion, specular, frost | | WebKit: Safari and every iOS browser | Frosted fallback: blur, tint and specular rim, no refraction | | Gecko: Firefox | Same frosted fallback |
The stylesheet also responds to prefers-reduced-transparency (solid
surface), prefers-contrast: more (near-opaque surface with a visible edge),
forced-colors and prefers-reduced-motion.
Only Blink renders SVG filters referenced from backdrop-filter. The
other engines accept the syntax but draw nothing, so a CSS cascade
fallback isn't reliable there. The component detects Blink and only adds
url(#filter) there.
Gotchas
No refraction at all, the page just shows through the glass? You have a backdrop root in the way.
- An ancestor with
filter,opacity < 1,mask,clip-path,mix-blend-mode,backdrop-filter, or children that blend into it, cuts the backdrop off at that ancestor. The glass then refracts an empty image. That's whybackdrop-filtersits on the component root, never on an inner layer.transformandoverflow: hiddenare fine. - Glass inside glass can't see the outer glass's refraction. Use siblings positioned on top (see Usage).
- Don't declare
backdrop-filteron.lgin your own CSS. Minifiers such as Lightning CSS (the Vite 8 default) can collapse abackdrop-filterand-webkit-backdrop-filterpair with equal values to the prefixed one alone, which Chromium ignores. To replace the filter, set--lg-backdrop-overrideinstead. - Give the glass something to bend. Over a flat colour, any glass looks like a tinted rectangle. Refraction shows over images, text and edges.
Static / zero-JS variant
For fixed-size elements, the same optics are available as a Python script
that writes a PNG and prints the matching scale:
pip install -r skills/liquid-glass/scripts/requirements.txt
python3 skills/liquid-glass/scripts/generate-displacement-map.py --width 700 --height 64 --radius 32 \
--output public/liquid-lens-map.png
# → Use it with: <feDisplacementMap scale="24.0" xChannelSelector="R" yChannelSelector="G" />Generate the map at the element's exact CSS size; a stretched map puts the bend in the wrong place.
Demo
Open the live demo — no installation required.
Glass lab · Showcase · Classic demo
Chromium shows the full refraction effect; other browsers get the frosted
fallback. The demo deploys to GitHub Pages after successful checks on main.
To run locally:
cd demo
npm install
npm run dev| URL | Scene |
| --- | --- |
| / | Homepage with draggable glass and links to all demos |
| /?page=lab | Squircle, orb and pill lenses; draggable with mouse, touch or arrow keys; live backdrop, style, tint, interactive and corner radius controls |
| /?page=showcase | Interactive artwork gallery, music interface and weather panel |
| /?page=classic | The original Music and Siri collection |
| /?scene=music | Apple Music style tab bar with sliding droplet, mini player and search button over album art |
| /?scene=siri | Dark glass panel over the macOS Tahoe wallpaper |
| /?scene=playground | Draggable lenses over text, stripes and a grid |
| /?scene=banner | The header image of this README |
| /?scene=original | The v1 technique, for comparison |
The demo imports @vepando/liquid-glass-react by name; Vite aliases that name to
src/, so you get HMR on the library source without a build step.
Before and after
For AI coding agents
skills/liquid-glass/ is a self-contained skill
(SKILL.md, assets and theory) that you can hand to Claude Code or any
other coding agent to implement the effect end-to-end, including the
troubleshooting table for the common failure modes.
Repository layout
src/ the package: LiquidGlass.tsx · useLiquidGlass.tsx · maps.ts · liquid-glass.css · index.ts
registry/glass/ source of the UI components; registry.json lists them for the shadcn CLI
dist/ build output (npm run build), published to npm
demo/ Vite + React showcase
scripts/ sync-skill.mjs (regenerates the skill's copy of src/)
skills/liquid-glass/ skill for AI coding agents
docs/ images used in this READMECredits
The refraction model follows the approach described by kube.io in Liquid Glass in the browser: refraction with CSS and SVG. Liquid Glass is a design language by Apple; this project is not affiliated with Apple.
The macOS Tahoe wallpapers in demo/public/img/ and in the images in docs/
are Apple's. They are only used for the demo and are not covered by this
project's MIT license or included in the npm package.
Development
npm install
npm run build # tsup → dist/ (ESM, CJS, .d.ts, style.css)
npm run typecheck
npm test # geometry and component tests; Node 22.18+ or 24+
npm run registry:build # shadcn registry → demo/public/r
cd demo && npm install && npm run devThe components are at ?scene=ui in the demo, and ?scene=bench&mode=dense
(also same, varied, panel, move, resize, scroll, nest) is the
measuring page.
Publishing: npm publish (runs typecheck and build first via
prepublishOnly).
