Astro buttons that lift and glint on hover in pure CSS — what I kept from glassmorphism
A shared CSS hover effect for a static Astro blog: lift, an accent-tinted sweep, accent border and glow, with Astro scoped-style and Pagefind specificity traps.

On this page
Introduction
I love glassmorphism components. On other sites I kept seeing cards that rise slightly under the pointer, catch a band of light sweeping across them, and light up at the edge, and I wanted the same feel on this blog’s buttons and tag pills.
My first worry was whether it needed JavaScript in the browser, because every page here is static HTML built ahead of time. It does not: every part of the effect is CSS the browser applies at paint time, so the pages stay exactly as static as before.
Building it also showed me that the traits I liked were never the glass but the lift, the sweep, the accent border and the glow. A blur only shows when something sits behind it, and this blog has a plain background, so I left the blur out.
All four now run as one shared class across the site’s buttons, tag pills and enlargeable figures.
What glassmorphism is
Glassmorphism is a UI style that draws a panel as frosted glass. Three CSS properties do most of the work:
- A translucent fill, such as
rgba(255, 255, 255, 0.1), so whatever sits behind the panel shows through. backdrop-filter: blur(10px), which blurs the part of the page behind the element rather than the element itself.- A faint light border, so the panel’s edge reads against the blurred backdrop.
A glassmorphism panel’s look depends on what is behind it. The style is usually shown over a vivid gradient or a photograph, because the blur needs shapes and colours to smear. With neither behind the panel, the blur shows nothing. This blog has no such background to show it on, so I did not use it.
The effects I liked work without the glass
Nothing I liked about those cards was frosting. The card rises a couple of pixels, a highlight sweeps across it, and its border lights up with a soft glow underneath. The lift is transform, the highlight is a moving gradient, and the border and glow are border-color and box-shadow. None of them needs backdrop-filter.
This blog’s page background is one flat colour, --bg-1, the site’s base surface (#ffffff in light mode, #0b1120 in dark). Blurring a single colour returns the same colour, so a glass panel here would render as a plain tinted box.
I also did not want a gradient behind the articles just to give the blur something to frost, since long-form text reads best on a solid surface.
One shared class for all four effects: .hover-glint
I wrote the four hover effects (lift, sweep, accent border and glow) as one global class, .hover-glint, controlled by four custom properties. Every colour in those properties comes from --accent, the link blue this blog uses for clickable text (#1d4ed8 in light mode, #60a5fa in dark).
--accent is defined with light-dark(), which picks its first value under a light colour scheme and its second under a dark one. So the glow and the band follow the theme with no extra tokens.
:root { --hover-lift: -2px; --hover-duration: 200ms; --hover-glow: 0 6px 16px color-mix(in srgb, var(--accent) 30%, transparent); --hover-sheen: linear-gradient( 100deg, transparent 40%, color-mix(in srgb, var(--accent) 15%, transparent) 50%, transparent 60% );}color-mix(in srgb, var(--accent) 30%, transparent) is the accent at 30% opacity. I picked the link blue over a new colour because it already marks what can be clicked, so a hover glow in the same blue says the same thing.
Apart from :focus-visible, the rules sit behind two media queries:
.hover-glint:focus-visible { border-color: var(--accent); box-shadow: var(--hover-glow);}
@media (hover: hover) { .hover-glint:hover { border-color: var(--accent); box-shadow: var(--hover-glow); }}
@media (hover: hover) and (prefers-reduced-motion: no-preference) { .hover-glint { transition: transform var(--hover-duration) ease, box-shadow var(--hover-duration) ease, border-color var(--hover-duration) ease, background-position 500ms ease; }
.hover-glint:hover { transform: translateY(var(--hover-lift)); background-position: 0 0; }
.hover-glint:active { transform: none; }}-
(hover: hover)tests thehovermedia feature, which asks whether the device can rest a pointer on an element.hovermeans it can, as with a mouse or the trackpad below a laptop keyboard.nonemeans it cannot, as with the touchscreen on a phone or tablet.
So
(hover: hover)takes effect only on computers, and phones and tablets ignore those rules. The split exists because on a touchscreen, a tap can leave the hover effect applied until the next tap elsewhere, which would leave the button lifted after the reader’s finger is gone. -
prefers-reduced-motion: no-preferencetests theprefers-reduced-motionmedia feature, which asks whether the reader has turned on Reduce Motion in their operating system.reducemeans it is on, for example through Reduce Motion in the macOS or iOS accessibility settings.no-preferencemeans it is off.
So readers without Reduce Motion see the lift and the sweep. Readers who turn it on are usually made dizzy or unwell by movement on screen, so they get only the accent border and glow, with no lift and no band crossing the button.
-
:focus-visiblegives keyboard users the border and glow without the movement. Both media queries above test(hover: hover), which a tablet with a keyboard attached does not match, so rules inside them never apply there. That is why:focus-visiblesits outside them.
I chose the elements that get the effect one by one:
- tag pills
- Load more
- the like, share and copy-link buttons
- the footer’s social icons
- the header search button
- the “Try again” button on the offline page
- figures that open the enlarge view
The mobile table of contents is left out, because hover never fires on the phones that card exists for. Search result rows get only the border and glow, because rows jumping under the pointer in a dense list is noise.
Why not a white band?
The highlight in a typical glass card is a white band at low opacity, which shows up well over a dark or saturated panel. On this blog’s light theme the cards sit on #ffffff, so a white band crosses them invisibly.
I made the band from the accent instead, at 15% opacity through color-mix(). It stays visible on white, still reads as light on the dark theme, and matches the glow beside it.


#60a5faThe sweep as a background layer
The common way to build a sweep is a ::before pseudo-element (an extra box CSS generates inside the element) holding the gradient, moved across with transform, while the button itself sets overflow: hidden to cut off the gradient where it leaves the button.
This blog cannot do that. The like and share buttons in the article side rail show their names as tooltips that hang outside the button, and overflow: hidden would cut those tooltips off too.
::before band with overflow: hidden leaves the button itself looking right, but the pointer entering the button shows a tooltip outside it. The tooltip is an element inside the button, so it gets cut off tooTo avoid that, I drew the band on the button’s own background image. The image is three times the button’s width, with the band in its middle third.
background-position decides which part of the image shows. At rest it is 100% 0, so the button shows the image’s right third. When the pointer enters, it moves gradually to 0 0 and the band sweeps from left to right; when the pointer leaves, it moves gradually back to 100% 0 and the band sweeps back from right to left.
A background also follows border-radius on its own, so pill shapes need nothing extra.
.hover-glint { background-image: var(--hover-sheen); background-size: 300% 100%; background-repeat: no-repeat; background-position: 100% 0;}100% 0 the button shows the image’s right third and the band sits to its left; once the image has slid right to 0 0, the button shows the left third and the band has crossed to its rightWith the band drawn on the background image, every element that gets the effect follows one rule: set background-color, never background. A component’s own rule overrides the global class, so a background: var(--bg-2) in the tag pill would delete the band without any warning. I switched every element in this change from background to background-color.
Who wins the border: Astro scoped styles
Astro limits the rules in a component’s <style> to that component, which is called a scoped style. It does this by adding a data-astro-cid-* attribute to every part of a selector, so .social a becomes .social[data-astro-cid-…] a[data-astro-cid-…]. Each added attribute raises the specificity, so a component’s rule often wins or ties against the global .hover-glint:hover and .hover-glint:focus-visible.
The built CSS shows which wins on border-color, each component’s base rule or the global class:
| Outcome | Example | Specificity | Components in this group |
|---|---|---|---|
| Global class wins | a[data-astro-cid-…] | (0,1,1) | tag pill, “Try again” button |
| Tie, decided by stylesheet load order | .load-more-button[data-astro-cid-…] | (0,2,0) | Load more, figure frame |
| Component wins | .social[data-astro-cid-…] a[data-astro-cid-…] | (0,3,1) | search button, like and share buttons, footer icons |
For components that win or tie on specificity, such as the like and share buttons, the component’s own setting overrides the global accent border, on hover and on keyboard focus alike. So I set the accent border in the own rules of the like and share buttons and the other components in that group, and listed :focus-visible beside :hover:
.social a:hover,.social a:focus-visible { border-color: var(--accent); color: var(--text-1); background-color: var(--bg-1);}With only :hover listed, components that win or tie on specificity, such as the like and share buttons, show the glow on keyboard focus but keep their old border. Adding :focus-visible makes the border take the accent colour on focus too.
Four :is(*, #\#) layers to override Pagefind
Pagefind, the static search library this blog uses, is harder to override than an Astro component. Its stylesheet wraps every rule in :is(*, #\#) three times.
Pagefind wraps its rules in three :is() layers, giving them three IDs of specificity so its own styles win. So I answered with the same trick, four layers deep, and the result-row rule wins with four IDs over Pagefind’s own styles:
@media (hover: hover) { :is(*, #\#):is(*, #\#):is(*, #\#):is(*, #\#) .pf-searchbox-result:hover { box-shadow: inset 0 0 0 1px var(--accent), var(--hover-glow); }}Summary
Tag pills, the main buttons and enlargeable figures on this blog now rise 2px under the pointer, take an accent border and glow, and have a faint band of the link blue sweep across them. All four effects live in one global class, so the site stays fully static and nothing extra loads.
Two rules keep it working on an Astro site:
- A component that gets the effect sets
background-color, neverbackground, or the shorthand deletes the band. - A component whose scoped rule outranks or ties with the class sets its own accent border on both
:hoverand:focus-visible.
References
- MDN: backdrop-filter
- MDN: Using media queries
- MDN: the hover media feature
- MDN: prefers-reduced-motion
- WCAG 2.2 Understanding 2.3.3: Animation from Interactions
- MDN: :is() and how its specificity is calculated
- MDN: Shorthand properties, and how they reset the sub-properties they leave out
- Astro docs: scoped styles
- Interaction Design Foundation: what glassmorphism is

