Liquid Glass Button
A pill of liquid glass that bends what is behind it: real refraction at the rim, a rim and sheen lit by your pointer, and a gel-like press.
- Category
- Components
- Added
- 2026-09-26
- Deps
- none
- Updated
- 2026-09-26
Props
"use client";
import { useEffect, useId, useRef } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
/**
* LiquidGlassButton — a pill of liquid glass, after Apple's 2025 material:
* it bends what is behind it rather than just blurring it.
*
* Three layers do the work:
* 1. REFRACTION. In Chromium an SVG displacement map, generated for the
* button's exact size, is applied inside backdrop-filter, so the backdrop
* visibly curves at the rim like light through a lens. Safari and Firefox
* cannot run SVG filters as a backdrop-filter yet; they get blur,
* saturation and brightness only, which still reads as glass.
* 2. RIM. A 1px rim graduated from bright (top) to faint (bottom), plus a
* second rim lit from wherever the pointer is.
* 3. SPECULAR. A soft highlight that follows the pointer across the surface.
*
* Press squashes it like gel and it springs back. Keyboard focus gets a
* visible ring. Glass needs something rich behind it: over a flat colour it
* is a grey pill, which is a property of glass, not of this component.
*
* Needs Tailwind v4 (or v3.4+). No dependencies beyond React.
*/
type Tone = "dark" | "light";
type Surface = "clear" | "frost";
type Size = "md" | "lg";
/** Allow relative paths, fragments, http(s), mailto and tel; anything else becomes "#". */
function safeHref(href: string): string {
const v = href.replace(/[\t\n\r]/g, "").trim();
if (v.indexOf(String.fromCharCode(92)) !== -1) return "#";
if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
return "#";
}
/** "#rrggbb" to "r,g,b", or null. Anything else is ignored, never interpolated. */
function rgbOf(hex?: string): string | null {
const m = /^#([0-9a-f]{6})$/i.exec(hex || "");
if (!m) return null;
const n = parseInt(m[1], 16);
return ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255);
}
/**
* The displacement map for a rounded rectangle, as a data URL. Red carries
* the x offset and green the y offset (128 = none). Offsets point inward and
* grow toward the rim, so the backdrop near the edge is pulled from further
* in: the magnified, bent edge of a lens.
*/
function lensMap(w: number, h: number, r: number, bezel: number): string {
const c = document.createElement("canvas");
c.width = w;
c.height = h;
const ctx = c.getContext("2d");
if (!ctx) return "";
const img = ctx.createImageData(w, h);
const hw = w / 2;
const hh = h / 2;
const sd = (x: number, y: number) => {
const qx = Math.abs(x) - (hw - r);
const qy = Math.abs(y) - (hh - r);
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
};
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
const x = i + 0.5 - hw;
const y = j + 0.5 - hh;
const d = sd(x, y);
let dx = 0;
let dy = 0;
const t = -d;
if (t > 0 && t < bezel) {
const nx = sd(x + 0.5, y) - sd(x - 0.5, y);
const ny = sd(x, y + 0.5) - sd(x, y - 0.5);
const len = Math.hypot(nx, ny) || 1;
const m = Math.pow(1 - t / bezel, 2);
dx = (-nx / len) * m;
dy = (-ny / len) * m;
}
const k = (j * w + i) * 4;
img.data[k] = Math.round(128 + 127 * dx);
img.data[k + 1] = Math.round(128 + 127 * dy);
img.data[k + 2] = 128;
img.data[k + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return c.toDataURL();
}
export function LiquidGlassButton({
children,
href,
onClick,
tone = "dark",
surface = "clear",
size = "md",
tint,
refraction = 1,
icon = true,
className = "",
}: {
children: ReactNode;
/** Renders a link instead of a button. Sanitised. */
href?: string;
onClick?: () => void;
/** Glass over a dark backdrop (white text) or a light one (ink text). */
tone?: Tone;
/** "clear" barely blurs, like Apple's clear variant; "frost" blurs more. */
surface?: Surface;
size?: Size;
/** Optional tint, "#rrggbb", mixed in at 15% (20% on light glass). */
tint?: string;
/** Refraction strength, 0–2. 0 turns the lens off. */
refraction?: number;
/** Trailing arrow that nudges on hover. */
icon?: boolean;
className?: string;
}) {
const rootRef = useRef<HTMLElement | null>(null);
const filterId = "lg" + useId().replace(/[^a-zA-Z0-9]/g, "");
const mapRef = useRef<SVGFEImageElement>(null);
const dispRef = useRef<SVGFEDisplacementMapElement>(null);
const blur = surface === "frost" ? 14 : 3;
const base = "blur(" + blur + "px) saturate(185%) brightness(" + (tone === "dark" ? 1.08 : 1.04) + ")";
const rgb = rgbOf(tint);
const fill =
tone === "dark"
? rgb
? "rgba(" + rgb + ",0.15)"
: "rgba(10,10,18,0.22)"
: rgb
? "rgba(" + rgb + ",0.2)"
: "rgba(255,255,255,0.34)";
// Build the lens for the button's real size, and switch the backdrop filter
// to the refracting version only where the browser runs SVG filters there.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ua = (navigator as Navigator & { userAgentData?: { brands?: { brand: string }[] } }).userAgentData;
const chromium = !!ua && !!ua.brands && ua.brands.some((b) => /Chromium/.test(b.brand));
if (!chromium || refraction <= 0) {
el.style.backdropFilter = base;
return;
}
const apply = () => {
const w = Math.round(el.offsetWidth);
const h = Math.round(el.offsetHeight);
if (!w || !h || !mapRef.current || !dispRef.current) return;
// A narrow rim: on a pill this thin a wide bezel would bend the whole
// surface and the middle would stop reading as clear glass.
const url = lensMap(w, h, h / 2, Math.min(h * 0.24, 12));
mapRef.current.setAttribute("href", url);
mapRef.current.setAttribute("width", String(w));
mapRef.current.setAttribute("height", String(h));
dispRef.current.setAttribute("scale", String(Math.round(28 * refraction)));
el.style.backdropFilter = "url(#" + filterId + ") " + base;
};
apply();
const ro = new ResizeObserver(apply);
ro.observe(el);
return () => ro.disconnect();
}, [filterId, base, refraction]);
// Track the pointer on move AND on press: a tap on a touch screen sends no
// move first, and the press glow must start under the finger.
function onPointerMove(e: ReactPointerEvent<HTMLElement>) {
const el = e.currentTarget;
const r = el.getBoundingClientRect();
el.style.setProperty("--lgx", ((e.clientX - r.left) / r.width) * 100 + "%");
el.style.setProperty("--lgy", ((e.clientY - r.top) / r.height) * 100 + "%");
el.style.setProperty("--lgo", "1");
}
function onPointerLeave(e: ReactPointerEvent<HTMLElement>) {
e.currentTarget.style.setProperty("--lgo", "0");
}
const ink = tone === "dark" ? "#ffffff" : "#111111";
const style = {
background: fill,
backdropFilter: base,
WebkitBackdropFilter: base,
color: ink,
boxShadow:
"inset 0 1px 0 rgba(255,255,255," + (tone === "dark" ? 0.55 : 0.8) + "), inset 0 -1px 0 rgba(255,255,255,0.18), 0 12px 40px rgba(0,0,0," + (tone === "dark" ? 0.28 : 0.12) + ")",
"--lgx": "30%",
"--lgy": "0%",
"--lgo": "0",
} as CSSProperties;
const inner = (
<>
{/* Pointer-lit rim: a 1px ring, brightest nearest the pointer. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
padding: 1,
background:
"radial-gradient(120px circle at var(--lgx) var(--lgy), rgba(255,255,255,calc(0.25 + var(--lgo) * 0.6)), rgba(255,255,255,0.14) 70%)",
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
}}
/>
{/* Specular: a soft sheen that travels with the pointer. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full transition-opacity duration-300"
style={{
opacity: "calc(0.35 + var(--lgo) * 0.65)",
background:
"radial-gradient(80% 140% at var(--lgx) var(--lgy), rgba(255,255,255,0.28), rgba(255,255,255,0) 60%)",
mixBlendMode: "screen",
}}
/>
{/* Press glow: the light starts under the pointer and spreads while held. */}
<span
aria-hidden
className="pointer-events-none absolute h-10 w-10 -translate-x-1/2 -translate-y-1/2 scale-50 rounded-full opacity-0 transition-all duration-200 ease-out group-active:scale-[7] group-active:opacity-100"
style={{
left: "var(--lgx)",
top: "var(--lgy)",
background: "radial-gradient(closest-side, rgba(255,255,255,0.4), rgba(255,255,255,0))",
}}
/>
<span
className="relative z-10 flex items-center gap-2 font-semibold tracking-[-0.01em]"
style={{ textShadow: tone === "dark" ? "0 1px 12px rgba(0,0,0,0.4)" : "none" }}
>
{children}
{icon && (
<span
aria-hidden
className="inline-block transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
>
↗
</span>
)}
</span>
<svg aria-hidden width="0" height="0" style={{ position: "absolute" }}>
<filter id={filterId} x="0" y="0" width="100%" height="100%" colorInterpolationFilters="sRGB">
<feImage ref={mapRef} x="0" y="0" preserveAspectRatio="none" result="map" />
<feDisplacementMap ref={dispRef} in="SourceGraphic" in2="map" scale="28" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
</>
);
const cls =
"group relative isolate inline-flex select-none items-center justify-center overflow-hidden rounded-full " +
(size === "lg" ? "h-14 px-7 text-[17px] " : "h-11 px-5 text-[15px] ") +
"transition-transform duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] active:scale-x-[0.98] active:scale-y-[0.97] active:duration-100 " +
"outline-none focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent " +
className;
if (href) {
return (
<a
ref={(n) => {
rootRef.current = n;
}}
href={safeHref(href)}
onClick={onClick}
onPointerMove={onPointerMove}
onPointerDown={onPointerMove}
onPointerLeave={onPointerLeave}
className={cls}
style={style}
>
{inner}
</a>
);
}
return (
<button
ref={(n) => {
rootRef.current = n;
}}
type="button"
onClick={onClick}
onPointerMove={onPointerMove}
onPointerDown={onPointerMove}
onPointerLeave={onPointerLeave}
className={cls}
style={style}
>
{inner}
</button>
);
}
About this component
Most glass buttons are a blurred rectangle with a white border, which is the 2020 version of the idea and now reads as frosted plastic. This one follows the 2025 material instead: it bends light. In Chromium browsers an SVG displacement map, generated at the button's exact size, runs inside backdrop-filter, so whatever sits behind the button curves at its rim like the edge of a lens. Safari and Firefox cannot run SVG filters as a backdrop-filter yet, so they get the same blur, saturation and brightness without the bend, and it still reads as glass rather than a broken effect. The rim is graduated, brightest along the top, with a second rim that lights up nearest your pointer, and a soft sheen follows the pointer across the surface. Pressing squashes it like gel and it springs back. It works as a link or a button, takes an optional tint, and has a clear and a frosted surface. Put it over something rich: glass over a flat colour is a grey pill.
Curator’s note
Part of the Glassmorphism kit, drop 1. Free because it is one control, but it carries the kit's whole material: lens, rim and sheen.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Glass Refraction Panel
componentsA card of liquid glass: it bends its backdrop at the rim, with a graduated edge and a light that follows the pointer.
Glass Bento Wall
sectionsA feature grid cut from one sheet of liquid glass: each tile is as thick as it is big, and one pointer light moves across every rim.
Glass Etch Text
text effectsReal, selectable heading text rendered as glass: a lit bevel, a graduated rim and a hairline colour fringe, raised or etched in.