Introduction
In this article we build Glass Button — a tactile, hardware-inspired glass button built entirely from HTML and CSS. The button has a frosted-glass surface created with backdrop-filter blur, a rotating conic-gradient border that spins on hover, a directional shine sweep that slides across the face on interaction, a blurred drop shadow that tightens on hover, and a perspective tilt using rotate3d on press. No JavaScript, no SVG, no canvas — just two wrapper divs, a button element, a span, and carefully layered CSS custom properties.
Every visual layer — the glass background, the animated border ring, the shine overlay, the soft drop shadow — is driven by CSS custom properties registered with @property. The @property registration is what makes gradient angles animatable: without it, browsers cannot interpolate between two gradient values because they treat them as discrete strings rather than typed angle values.
How to register animatable custom properties with @property so CSS can interpolate gradient angles — how to build a rotating conic-gradient border using mask-composite: exclude — how to layer backdrop-filter blur to create a true frosted-glass effect — how to animate a shine sweep using background-position on a diagonal linear-gradient — how to fake a physical press with rotate3d and a multi-layer box-shadow state change — and how to handle the hover: none media query so touch devices receive the correct static styles.
Step 1 — CSS Custom Properties & @property
Two custom properties control the animated angles. They must be registered with @property rather than declared in :root because CSS can only interpolate values it understands the type of. A plain --angle: -75deg in :root is just a string — the browser has no idea it is an angle and will snap between values rather than smoothly interpolating. The @property block tells the browser the syntax is <angle>, so transition and animation work correctly.
/* must be at the top level — not inside a selector */
@property --border-angle {
syntax: "<angle>";
inherits: false;
initial-value: -75deg; /* starting rotation of the conic border */
}
@property --shine-angle {
syntax: "<angle>";
inherits: false;
initial-value: -45deg; /* starting angle of the shine gradient */
}
:root {
--btn-ease: cubic-bezier(0.25, 1, 0.5, 1); /* snappy ease-out */
--btn-duration: 400ms;
--btn-border: clamp(1px, 0.0625em, 4px); /* scales with font-size */
}The button uses em units throughout so it scales proportionally with font-size. A fixed 1px border looks too thin at large sizes and too thick at small sizes. clamp(1px, 0.0625em, 4px) keeps the border at exactly one sixteenth of the current em — proportional to the button — but clamps it so it never collapses below a physical pixel or grows beyond 4px at extreme sizes.
Step 2 — The Markup
The component needs three layers of wrapping. The outermost .glass-btn-wrapper handles the 3D perspective tilt on press and pointer-events routing — it must have pointer-events: none so clicks pass through to the button. The .glass-btn-shadow sibling is a separate div that holds the blurred drop shadow; keeping it as a separate element rather than using filter: drop-shadow on the button itself prevents the blur from clipping the button edges. The button itself holds the glass surface, the animated border, and the shine overlay via its ::after pseudo-element on the inner span.
<div class="glass-btn-wrapper">
<!-- the button — holds the glass surface and all pseudo-element layers -->
<button class="glass-btn">
<!-- span drives the shine sweep via ::after and carries the label text -->
<span class="glass-btn__label">Glass Button</span>
</button>
<!-- separate element for the blurred drop shadow -->
<!-- kept outside the button so blur does not clip button edges -->
<div class="glass-btn-shadow"></div>
</div>| Element | Role | Animated |
|---|---|---|
| .glass-btn-wrapper | Perspective container — rotate3d tilt on :active, pointer-events: none so clicks reach the button | Yes — rotate3d on :active |
| .glass-btn | Glass surface — backdrop-filter blur, diagonal gradient bg, layered box-shadows | Yes — transform scale + box-shadow on :hover/:active |
| .glass-btn::after | Conic-gradient border ring — mask-composite: exclude punches a hole through to show only the border | Yes — --border-angle on :hover/:active |
| .glass-btn__label | Text node — carries padding that sizes the button, text-shadow for depth | Yes — text-shadow shift on :hover/:active |
| .glass-btn__label::after | Shine sweep overlay — linear-gradient sliding via background-position | Yes — background-position + --shine-angle on :hover/:active |
| .glass-btn-shadow | Drop shadow element — filter: blur applied here, not on the button | Yes — filter blur tightens on :hover |
Step 3 — Building the Frosted Glass Surface
The glass effect is created by three properties working together. First, backdrop-filter: blur() blurs whatever is behind the button, creating the frosted-glass look. Second, the button background is a diagonal linear-gradient with very low-opacity whites — not a solid colour — so the blurred background shows through. Third, a multi-layer box-shadow adds the physical depth cues: an inset dark shadow at the top-left simulates a recessed surface, an inset light shadow at the bottom simulates a lit rim, and an outer shadow lifts the button off the page.
.glass-btn {
all: unset; /* reset all browser button defaults */
cursor: pointer;
position: relative;
z-index: 3;
border-radius: 999vw; /* full pill — 999vw is safer than 50% for non-square elements */
/* the glass: a near-transparent diagonal gradient over a blur */
background: linear-gradient(
-75deg,
rgba(255, 255, 255, 0.05), /* dark end — very low opacity */
rgba(255, 255, 255, 0.20), /* bright midpoint */
rgba(255, 255, 255, 0.05) /* dark end */
);
/* frosted blur — blurs the content behind the button */
backdrop-filter: blur(clamp(1px, 0.125em, 4px));
/* four shadow layers in one declaration */
box-shadow:
inset 0 0.125em 0.125em rgba(0, 0, 0, 0.05), /* top inset dark */
inset 0 -0.125em 0.125em rgba(255,255,255,0.50), /* bottom inset light */
0 0.25em 0.125em -0.125em rgba(0, 0, 0, 0.20), /* outer lift */
inset 0 0 0.1em 0.25em rgba(255,255,255,0.20); /* inner ambient glow */
transition: all var(--btn-duration) var(--btn-ease),
--border-angle 500ms ease;
}backdrop-filter only works when the element has a background with less than 100% opacity. A fully transparent background shows the blur but loses the glass colour. A fully opaque background hides the blur entirely. The sweet spot is a near-transparent gradient — enough colour to read as glass, enough transparency to show the blur.
Step 4 — The Rotating Conic-Gradient Border Ring
The border is not a CSS border property — it is a pseudo-element with a conic-gradient background and a mask that reveals only the outer ring. This technique allows the border colour to vary around the circumference, which a plain border cannot do. The conic-gradient rotates from --border-angle and places dark segments at the top and bottom (where a real object would cast its own shadow on the rim) and transparent segments at the sides. A white linear-gradient is composited underneath to give the rim a baseline white sheen.
.glass-btn::after {
content: "";
position: absolute;
z-index: 1;
inset: 0;
border-radius: 999vw;
/* expand slightly to sit outside the button edge */
width: calc(100% + var(--btn-border));
height: calc(100% + var(--btn-border));
top: calc(0% - var(--btn-border) / 2);
left: calc(0% - var(--btn-border) / 2);
/* border thickness — the padding creates the visible ring width */
padding: var(--btn-border);
box-sizing: border-box;
/* the border gradient: conic for the dark accents + linear for the base sheen */
background:
conic-gradient(
from var(--border-angle) at 50% 50%,
rgba(0, 0, 0, 0.5), /* dark segment — top */
rgba(0, 0, 0, 0.0) 5% 40%, /* transparent — sides */
rgba(0, 0, 0, 0.5) 50%, /* dark segment — bottom */
rgba(0, 0, 0, 0.0) 60% 95%, /* transparent — sides */
rgba(0, 0, 0, 0.5) /* dark segment — back to top */
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.5),
rgba(255, 255, 255, 0.5)
);
/* mask-composite: exclude punches a hole through the padding area */
/* revealing the gradient only where the padding ring is — the border */
mask: linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
/* inset white line along the inner edge of the border */
box-shadow: inset 0 0 0 calc(var(--btn-border) / 2) rgba(255, 255, 255, 0.5);
transition: all var(--btn-duration) var(--btn-ease),
--border-angle 500ms ease;
}
/* hover: border rotates 50deg clockwise — simulates a light source moving */
.glass-btn:hover::after { --border-angle: -125deg; }
/* active: border snaps back to default — the press resets the rotation */
.glass-btn:active::after { --border-angle: -75deg; }The pseudo-element has padding equal to the desired border width. mask-composite: exclude takes two mask layers — the content-box (the inner padded area) and the border-box (the full element) — and subtracts the inner from the outer. The result is a mask that is opaque only in the padding ring, which is exactly where the border should be visible. The gradient background shows through only in that ring, creating a border effect with full gradient control.
Step 5 — The Directional Shine Sweep
The shine effect is a white diagonal gradient on the ::after pseudo-element of the label span. The gradient is set to 200% width so only half of it is visible at any time. Animating background-position slides the visible portion across the face. On hover, the bright midpoint slides toward the centre. On active (press), it shoots toward the top corner and the --shine-angle rotates to simulate the light source shifting as the surface tilts. The mix-blend-mode: screen blending mode ensures the shine adds light without ever making the surface look flat white.
.glass-btn__label::after {
content: "";
display: block;
position: absolute;
z-index: 3;
/* slightly inset from the button edge to prevent overlap with the border ring */
width: calc(100% - var(--btn-border));
height: calc(100% - var(--btn-border));
top: calc(0% + var(--btn-border) / 2);
left: calc(0% + var(--btn-border) / 2);
box-sizing: border-box;
border-radius: 999vw;
overflow: clip; /* hard-clips the oversized gradient to the pill shape */
/* the shine: a white stripe on a diagonal, mostly transparent */
background: linear-gradient(
var(--shine-angle),
rgba(255, 255, 255, 0.0) 0%,
rgba(255, 255, 255, 0.5) 40% 50%, /* bright stripe — narrow band */
rgba(255, 255, 255, 0.0) 55%
);
background-size: 200% 200%; /* double size so only part is visible */
background-position: 0% 50%; /* default: shine parked off to the left */
background-repeat: no-repeat;
mix-blend-mode: screen; /* adds light, never flattens */
pointer-events: none; /* must not intercept clicks */
transition:
background-position calc(var(--btn-duration) * 1.25) var(--btn-ease),
--shine-angle calc(var(--btn-duration) * 1.25) var(--btn-ease);
}
/* hover: slide the bright stripe toward centre */
.glass-btn:hover .glass-btn__label::after {
background-position: 25% 50%;
}
/* active: shoot the stripe to the top corner + rotate angle to match tilt */
.glass-btn:active .glass-btn__label::after {
background-position: 50% 15%;
--shine-angle: -15deg;
}Step 6 — The Soft Blurred Drop Shadow
The drop shadow is handled by a separate .glass-btn-shadow sibling div rather than a box-shadow or filter on the button itself. This separation matters because filter: blur() applied to the button would blur all of its children including the label text and the border ring. By isolating the shadow to its own element, the blur radius can be animated independently without affecting anything else. The shadow element uses a ::after pseudo-element positioned to match the button footprint, with a gradient background and a large filter blur.
.glass-btn-shadow {
--cutoff: 2em; /* oversized container so the blur does not get clipped */
position: absolute;
width: calc(100% + var(--cutoff));
height: calc(100% + var(--cutoff));
top: calc(0% - var(--cutoff) / 2);
left: calc(0% - var(--cutoff) / 2);
/* the blur lives here — separated from the button */
filter: blur(clamp(2px, 0.125em, 12px));
overflow: visible;
pointer-events: none;
}
/* the actual shadow shape — a rounded rect with a vertical gradient */
.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));
/* inset to match the button footprint inside the oversized container */
width: calc(100% - var(--cutoff) - 0.25em);
height: calc(100% - var(--cutoff) - 0.25em);
top: calc(var(--cutoff) - 0.5em);
left: calc(var(--cutoff) - 0.875em);
padding: 0.125em;
box-sizing: border-box;
/* same mask trick as the border — reveals only the outer ring of the shadow */
mask: linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
transition: all var(--btn-duration) var(--btn-ease);
}
/* hover: tighten the blur — shadow sharpens as if button is closer to the surface */
.glass-btn-wrapper:has(.glass-btn:hover) .glass-btn-shadow {
filter: blur(clamp(2px, 0.0625em, 6px));
}
/* hover: shift shadow down slightly — button appears to move toward the page */
.glass-btn-wrapper:has(.glass-btn:hover) .glass-btn-shadow::after {
top: calc(var(--cutoff) - 0.875em);
}
/* active: shadow softens and fades — button is pressed flush */
.glass-btn-wrapper:has(.glass-btn:active) .glass-btn-shadow::after {
top: calc(var(--cutoff) - 0.5em);
opacity: 0.75;
}A sharp shadow reads as close to the surface; a soft blurred shadow reads as elevated. Tightening the blur radius on hover gives the impression that the button has descended toward the page, reinforcing the physical press sensation before the finger has even lifted.
Step 7 — Simulating a Physical Press with rotate3d
On :active, the wrapper div tilts using rotate3d(1, 0, 0, 25deg) — a rotation around the X axis that makes the top of the button tip away from the viewer as if it is being physically depressed. At the same time, the button itself gets a new box-shadow state: a strong inset shadow at the top (the surface is now recessed), a light outer line at the bottom (the bottom edge catches the light as it tips forward), and an overall slightly darker appearance. The parent wrapper handles the tilt so the shadow element tilts with it, keeping the shadow in the correct position relative to the tilted button.
/* wrapper tilts on press — rotate3d for perspective-correct X-axis rotation */
.glass-btn-wrapper:has(.glass-btn:active) {
transform: rotate3d(1, 0, 0, 25deg);
}
/* button box-shadow on press — recessed surface + bottom rim light + deep inset */
.glass-btn-wrapper: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.50),
0 0.125em 0.125em -0.125em rgba(0, 0, 0, 0.20),
inset 0 0 0.1em 0.25em rgba(255,255,255,0.20),
0 0.225em 0.05em 0 rgba(0, 0, 0, 0.05), /* outer bottom rim */
0 0.25em 0 0 rgba(255,255,255, 0.75), /* white bottom line */
inset 0 0.25em 0.05em 0 rgba(0, 0, 0, 0.15); /* deep top inset */
}
/* text shadow shifts downward on press — matches the tilt direction */
.glass-btn-wrapper:has(.glass-btn:active) .glass-btn__label {
text-shadow: 0.025em 0.25em 0.05em rgba(0, 0, 0, 0.12);
}Step 8 — Handling Touch Devices
Touch devices fire :active but never fire :hover in the same way desktop browsers do. Without a media query override, the hover state can get stuck on mobile after a tap. The @media (hover: none) and (pointer: coarse) block resets the animated angles to their default values so the border and shine are always in their resting position on touch screens — the press (:active) still fires and gives tactile feedback, but the hover sweep animation is disabled.
@media (hover: none) and (pointer: coarse) {
/* lock shine angle to default — no hover sweep on touch */
.glass-btn__label::after,
.glass-btn:active .glass-btn__label::after {
--shine-angle: -45deg;
}
/* lock border angle to default — no rotation on touch */
.glass-btn::after,
.glass-btn:hover::after,
.glass-btn:active::after {
--border-angle: -75deg;
}
}hover: none alone matches some stylus devices and hybrid laptops in touch mode. pointer: coarse alone matches some low-precision pointing devices that still support hover. The combination targets specifically devices with a touch screen as the primary input — a fat imprecise pointer that does not hover — which is the exact class of device where the hover animation causes problems.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --btn-duration | 400ms | Speed of all transitions. Lower for a snappier feel, higher for a slow reveal |
| --btn-ease | cubic-bezier(0.25, 1, 0.5, 1) | Easing for all transitions. Controls how quickly the animation decelerates |
| --btn-border | clamp(1px, 0.0625em, 4px) | Border ring thickness. Scales with font-size. Increase for a chunkier rim |
| --border-angle initial-value | -75deg | Starting rotation of the conic border. Changes which side has the dark accent |
| --border-angle on :hover | -125deg | Border rotates 50deg clockwise on hover. Increase delta for a more dramatic spin |
| --shine-angle initial-value | -45deg | Starting angle of the shine stripe. 0deg = vertical, -45deg = diagonal |
| --shine-angle on :active | -15deg | Shine flattens toward horizontal on press. Change to match your tilt direction |
| backdrop-filter blur | clamp(1px, 0.125em, 4px) | Glass frosting intensity. Higher = more blurred background, heavier GPU cost |
| rotate3d on :active | rotate3d(1, 0, 0, 25deg) | Press tilt angle. Increase for a more dramatic tip, decrease for a subtle dip |
| shadow filter blur (default) | clamp(2px, 0.125em, 12px) | Drop shadow softness at rest. Larger blur reads as more elevated |
| shadow filter blur (hover) | clamp(2px, 0.0625em, 6px) | Drop shadow tightens on hover — button appears to descend toward the page |
| background-position on :hover | 25% 50% | How far the shine sweeps in on hover. 50% 50% brings it fully to centre |
Full Source Code
Save the following as glass-button.html and open in any modern browser. backdrop-filter requires Chrome 76+, Safari 9+, or Firefox 103+. The @property registration requires Chrome 85+, Safari 16.4+, or Firefox 128+. On older browsers the button degrades gracefully — the glass blur and gradient animation are lost but the button remains fully functional and styled.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/glass-button.htmlEvery visual layer in Glass Button — the frosted glass surface, the rotating conic border, the directional shine sweep, the physics-informed press tilt — is pure CSS. The only HTML is a div, a button, and a span. Not a single line of JavaScript touches the component.