Skip to content
← components

Liquid Glass Button

new

A frosted backdrop-blur button with layered inset highlights, an animated conic-gradient border, a moving specular sheen, and a shadow that tilts on press. Ported from Petr Knoll.

hover for the sheen · press to tilt

Usage

example.tsx
import { GlassButton } from "~/components/buttons/glass-button";
<GlassButton onClick={generate}>Generate</GlassButton>

What's going on

This is Petr Knoll's button, and it's a small masterclass in stacking cheap effects. The pill is translucent with a backdrop-filter: blur, so it actually frosts whatever's behind it (that's why it wants a busy, light background to sit on). Two inset shadows, a dark one up top and a light one along the bottom, give it a glassy edge.

The two moving parts both lean on @property. CSS can't normally animate the angle inside a gradient, but registering an angle custom property as <angle> makes it interpolatable. So the conic-gradient border (masked down to a hairline with mask-composite: exclude) and the diagonal sheen across the face both rotate smoothly on hover and press. A separate blurred element underneath is the shadow, and on :active the whole wrap tilts back with rotate3d so it feels like it's being pushed.

It's all em-based, so it scales with font-size, and it degrades on touch (the angle animations freeze rather than jump).

glass-button.css
/* Animatable angles, so the border + sheen can transition smoothly. */
@property --glass-1 { syntax: "<angle>"; inherits: false; initial-value: -75deg; }
@property --glass-2 { syntax: "<angle>"; inherits: false; initial-value: -45deg; }
.glass-btn {
all: unset;
background: linear-gradient(-75deg,
rgba(255,255,255,.05), rgba(255,255,255,.2), rgba(255,255,255,.05));
backdrop-filter: blur(clamp(1px, .125em, 4px)); /* the frost */
box-shadow:
inset 0 .125em .125em rgba(0,0,0,.05),
inset 0 -.125em .125em rgba(255,255,255,.5), /* bottom rim light */
0 .25em .125em -.125em rgba(0,0,0,.2); /* soft lift */
}
/* Conic-gradient border masked to a hairline; the angle animates on hover. */
.glass-btn::after {
background: conic-gradient(from var(--glass-1) at 50% 50%,);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
}
.glass-btn:hover::after { --glass-1: -125deg; }

Props

proptypedefaultdescription
childrenReactNode"Generate"Button label.
…propsButtonHTMLAttributesAny native button prop (onClick, type, aria-*).

Original by Petr Knoll.

The full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).

components/buttons/glass-button.tsx
import { cn } from "~/lib/utils";
export interface GlassButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
/**
* A frosted "liquid glass" button: a translucent `backdrop-filter` pill with
* layered inset highlights, an animated conic gradient border and a moving
* specular sheen (both via `@property` angle transitions), and a separate
* blurred shadow element that lifts and tilts on press. Reads best on a light,
* slightly busy background. Styling lives in `.glass-btn-*` (app.css).
* Ported from Petr Knoll's CodePen.
*/
export function GlassButton({
children = "Generate",
className,
type = "button",
...props
}: GlassButtonProps) {
return (
<span className={cn("glass-btn-wrap", className)}>
<button type={type} className="glass-btn" {...props}>
<span>{children}</span>
</button>
<span className="glass-btn-shadow" aria-hidden="true" />
</span>
);
}
app/styles/app.css
/* ─── Liquid-glass button, ported from Petr Knoll ──────────────────── */
@property --glass-1 {
syntax: "<angle>";
inherits: false;
initial-value: -75deg;
}
@property --glass-2 {
syntax: "<angle>";
inherits: false;
initial-value: -45deg;
}
.glass-btn-wrap {
--anim--hover-time: 400ms;
--anim--hover-ease: cubic-bezier(0.25, 1, 0.5, 1);
position: relative;
z-index: 2;
display: inline-block;
border-radius: 999vw;
pointer-events: none;
transition: all var(--anim--hover-time) var(--anim--hover-ease);
}
.glass-btn-shadow {
--shadow-cuttoff-fix: 2em;
position: absolute;
width: calc(100% + var(--shadow-cuttoff-fix));
height: calc(100% + var(--shadow-cuttoff-fix));
top: calc(0% - var(--shadow-cuttoff-fix) / 2);
left: calc(0% - var(--shadow-cuttoff-fix) / 2);
filter: blur(clamp(2px, 0.125em, 12px));
overflow: visible;
pointer-events: none;
}
.glass-btn-shadow::after {
content: "";
position: absolute;
z-index: 0;
inset: 0;
border-radius: 999vw;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.1));
width: calc(100% - var(--shadow-cuttoff-fix) - 0.25em);
height: calc(100% - var(--shadow-cuttoff-fix) - 0.25em);
top: calc(var(--shadow-cuttoff-fix) - 0.5em);
left: calc(var(--shadow-cuttoff-fix) - 0.875em);
padding: 0.125em;
box-sizing: border-box;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
transition: all var(--anim--hover-time) var(--anim--hover-ease);
overflow: visible;
opacity: 1;
}
.glass-btn {
--border-width: clamp(1px, 0.0625em, 4px);
all: unset;
cursor: pointer;
position: relative;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
pointer-events: auto;
z-index: 3;
font-size: clamp(1.1rem, 2.6vw, 1.6rem);
background: linear-gradient(
-75deg,
rgba(255, 255, 255, 0.05),
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.05)
);
border-radius: 999vw;
box-shadow:
inset 0 0.125em 0.125em rgba(0, 0, 0, 0.05),
inset 0 -0.125em 0.125em rgba(255, 255, 255, 0.5),
0 0.25em 0.125em -0.125em rgba(0, 0, 0, 0.2),
0 0 0.1em 0.25em inset rgba(255, 255, 255, 0.2),
0 0 0 0 rgba(255, 255, 255, 1);
backdrop-filter: blur(clamp(1px, 0.125em, 4px));
-webkit-backdrop-filter: blur(clamp(1px, 0.125em, 4px));
transition: all var(--anim--hover-time) var(--anim--hover-ease);
}
.glass-btn:hover {
transform: scale(0.975);
backdrop-filter: blur(0.01em);
-webkit-backdrop-filter: blur(0.01em);
box-shadow:
inset 0 0.125em 0.125em rgba(0, 0, 0, 0.05),
inset 0 -0.125em 0.125em rgba(255, 255, 255, 0.5),
0 0.15em 0.05em -0.1em rgba(0, 0, 0, 0.25),
0 0 0.05em 0.1em inset rgba(255, 255, 255, 0.5),
0 0 0 0 rgba(255, 255, 255, 1);
}
.glass-btn span {
position: relative;
display: block;
user-select: none;
letter-spacing: -0.05em;
font-weight: 500;
font-size: 1em;
color: rgba(50, 50, 50, 1);
text-shadow: 0em 0.25em 0.05em rgba(0, 0, 0, 0.1);
transition: all var(--anim--hover-time) var(--anim--hover-ease);
padding-inline: 1.5em;
padding-block: 0.875em;
}
.glass-btn:hover span {
text-shadow: 0.025em 0.025em 0.025em rgba(0, 0, 0, 0.12);
}
.glass-btn span::after {
content: "";
display: block;
position: absolute;
z-index: 1;
width: calc(100% - var(--border-width));
height: calc(100% - var(--border-width));
top: calc(0% + var(--border-width) / 2);
left: calc(0% + var(--border-width) / 2);
box-sizing: border-box;
border-radius: 999vw;
overflow: clip;
background: linear-gradient(
var(--glass-2),
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.5) 40% 50%,
rgba(255, 255, 255, 0) 55%
);
z-index: 3;
mix-blend-mode: screen;
pointer-events: none;
background-size: 200% 200%;
background-position: 0% 50%;
background-repeat: no-repeat;
transition:
background-position calc(var(--anim--hover-time) * 1.25) var(--anim--hover-ease),
--glass-2 calc(var(--anim--hover-time) * 1.25) var(--anim--hover-ease);
}
.glass-btn:hover span::after {
background-position: 25% 50%;
}
.glass-btn:active span::after {
background-position: 50% 15%;
--glass-2: -15deg;
}
.glass-btn::after {
content: "";
position: absolute;
z-index: 1;
inset: 0;
border-radius: 999vw;
width: calc(100% + var(--border-width));
height: calc(100% + var(--border-width));
top: calc(0% - var(--border-width) / 2);
left: calc(0% - var(--border-width) / 2);
padding: var(--border-width);
box-sizing: border-box;
background: conic-gradient(
from var(--glass-1) at 50% 50%,
rgba(0, 0, 0, 0.5),
rgba(0, 0, 0, 0) 5% 40%,
rgba(0, 0, 0, 0.5) 50%,
rgba(0, 0, 0, 0) 60% 95%,
rgba(0, 0, 0, 0.5)
),
linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5));
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
transition:
all var(--anim--hover-time) var(--anim--hover-ease),
--glass-1 500ms ease;
box-shadow: inset 0 0 0 calc(var(--border-width) / 2) rgba(255, 255, 255, 0.5);
}
.glass-btn:hover::after {
--glass-1: -125deg;
}
.glass-btn:active::after {
--glass-1: -75deg;
}
.glass-btn-wrap:has(.glass-btn:hover) .glass-btn-shadow {
filter: blur(clamp(2px, 0.0625em, 6px));
}
.glass-btn-wrap:has(.glass-btn:hover) .glass-btn-shadow::after {
top: calc(var(--shadow-cuttoff-fix) - 0.875em);
opacity: 1;
}
.glass-btn-wrap:has(.glass-btn:active) {
transform: rotate3d(1, 0, 0, 25deg);
}
.glass-btn-wrap:has(.glass-btn:active) .glass-btn {
box-shadow:
inset 0 0.125em 0.125em rgba(0, 0, 0, 0.05),
inset 0 -0.125em 0.125em rgba(255, 255, 255, 0.5),
0 0.125em 0.125em -0.125em rgba(0, 0, 0, 0.2),
0 0 0.1em 0.25em inset rgba(255, 255, 255, 0.2),
0 0.225em 0.05em 0 rgba(0, 0, 0, 0.05),
0 0.25em 0 0 rgba(255, 255, 255, 0.75),
inset 0 0.25em 0.05em 0 rgba(0, 0, 0, 0.15);
}
.glass-btn-wrap:has(.glass-btn:active) span {
text-shadow: 0.025em 0.25em 0.05em rgba(0, 0, 0, 0.12);
}
@media (hover: none) and (pointer: coarse) {
.glass-btn span::after,
.glass-btn:active span::after {
--glass-2: -45deg;
}
.glass-btn::after,
.glass-btn:hover::after,
.glass-btn:active::after {
--glass-1: -75deg;
}
}