Introduction
There is no image, no SVG filter, and no JavaScript animation loop here — just a button, a stack of gradients and inset shadows that read as frosted glass, and one CSS custom property that gets redefined the moment the pointer arrives. Nothing "renders"; the browser’s own box-shadow and background-gradient engine does all the shading, and the only thing that ever changes on hover is a handful of RGB triplets stored in variables.
The buttons also sit on a tilted, isometric-looking board, which is the one part of this build that looks like a 3D scene but deliberately is not one — and that distinction turns out to be the difference between hover working everywhere on the button and hover only working in a few stray patches.
Why stacking several box-shadows (inset and outset) does more for a "glass" look than any single blur or backdrop-filter — how a CSS custom property lets one hover selector recolor background, shadow, and glow together — how a separate blurred glow element sits behind the button on its own z-index — and why true CSS 3D (transform-style: preserve-3d) on sibling elements can silently break which part of a button receives your mouse.
Step 1 — A Gradient Fill, Then Shadows Doing the Real Work
The button’s base is an ordinary vertical gradient from near-white into a tinted color, but the glass feeling comes almost entirely from four stacked box-shadows: a thin white inset line at the top edge, a soft dark inset gradient along the bottom to fake internal depth, a tight drop shadow for contact with the surface, and a large soft drop shadow for the button "floating" above the board.
.liquid-btn {
background: linear-gradient(
180deg,
rgba(255,255,255,0.95) 0%,
rgba(var(--tint), 0.55) 45%,
rgba(var(--tint-strong), 0.85) 100%
);
box-shadow:
0 2px 0 rgba(255,255,255,0.9) inset,
0 -18px 26px -14px rgba(0,0,0,0.18) inset,
0 14px 0 -6px rgba(0,0,0,0.05),
0 26px 40px -14px rgba(20,20,25,0.35),
0 2px 4px rgba(20,20,25,0.15);
}Nothing here is blurred with backdrop-filter, which matters for performance — every one of these buttons can repaint on hover at 60fps because the "glass" is just gradients and shadows, not a live blur sampling whatever sits behind it.
Step 2 — Pseudo-Elements for the Highlight and the Rim
Two pseudo-elements finish the glass illusion. The ::before is a small blurred white gradient pinned to the upper-left of the pill, standing in for a light source hitting curved glass. The ::after is the full size of the button with only an inset box-shadow, drawing a thin bright rim around the whole edge. Both carry pointer-events: none, so they are purely decorative and never intercept the hover that the button itself needs to receive.
.liquid-btn::before {
content: "";
position: absolute;
top: 6px; left: 18px; right: 40%;
height: 30%;
border-radius: 40px;
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(255,255,255,0));
filter: blur(1px);
pointer-events: none;
}
.liquid-btn::after {
content: "";
position: absolute;
inset: 0;
border-radius: 46px;
box-shadow: 0 0 0 1px rgba(255,255,255,0.6) inset;
pointer-events: none;
}Step 3 — --tint, --tint-strong, and --glow
Every color-dependent rule in the button — the gradient, the shadow tinting, the glow — reads from three custom properties instead of hard-coded colors. The default values are all white, so an untouched button looks like plain frosted glass. Each button then carries a data-color attribute, and a single :hover rule per color simply redefines those three variables, which cascades into every property that already references them.
.liquid-btn {
--tint: 255,255,255;
--tint-strong: 255,255,255;
--glow: transparent;
}
.liquid-btn[data-color="orange"]:hover {
--tint: 255,196,120;
--tint-strong: 255,158,53;
--glow: rgba(255,150,40,0.55);
}No JavaScript ever runs on hover — the button doesn’t know it turned orange. It only knows three numbers changed, and every gradient and shadow that reads those numbers repaints itself automatically.
Step 4 — The Glow Ring Is Its Own Element
The soft colored halo around a hovered button is not a shadow on the button itself — box-shadow blur radii large enough to read as a "glow" also blur the button’s own edges. Instead it is a separate absolutely-positioned span, slightly larger than the button, filled with the --glow variable and blurred at 10px, sitting behind the button at z-index: -1 with opacity 0 by default and opacity .9 on hover.
.liquid-btn .glow-ring {
position: absolute;
inset: -3px;
border-radius: 48px;
background: var(--glow);
filter: blur(10px);
opacity: 0;
transition: opacity .3s ease;
z-index: -1;
}
.liquid-btn:hover .glow-ring { opacity: .9; }Step 5 — Why preserve-3d Was the Bug, Not the Fix
The isometric look comes from a single transform on the whole board — perspective on the page, rotateX and rotateZ on one wrapping .stage element. The first version also put transform-style: preserve-3d and translateZ on every individual button, meant to lift each one toward the camera on hover. That put every button into the same real 3D space, and the browser started depth-sorting them by true z-position instead of DOM order, so a corner of one button could render in front of its neighbor and silently steal its hover events.
/* the fix: flatten every button back into 2D */
.stage {
transform: rotateX(48deg) rotateZ(-38deg);
/* no transform-style: preserve-3d here */
}
.liquid-btn {
/* no transform-style: preserve-3d, no translateZ */
transition: transform .25s cubic-bezier(.2,.9,.3,1.3),
box-shadow .3s ease, background .3s ease;
z-index: 1;
}
.liquid-btn:hover {
transform: translateY(-4px) scale(1.015);
z-index: 5;
}Reach for preserve-3d only when siblings genuinely need to occupy different depths relative to each other. A single group-level tilt with flat children keeps paint order equal to DOM order, so z-index and :hover behave exactly the way they look like they should.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --tint / --tint-strong | 255,196,120 / 255,158,53 | Mid and end stops of the glass gradient on hover |
| --glow | rgba(255,150,40,0.55) | Fill color of the blurred glow-ring element |
| .stage rotateX / rotateZ | 48deg / -38deg | Overall tilt angle of the whole button board |
| glow-ring filter blur | 10px | Softness of the colored halo behind a hovered button |
| hover transform | translateY(-4px) scale(1.015) | Lift amount, kept 2D to avoid depth-sorting siblings |
Full Source Code
The whole thing is a single self-contained HTML file — gradient buttons, color variables, glow rings, and the flattened tilt wrapper, with no build step and no external dependencies.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/liquid-buttons.html