# 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.

- Source: https://oharu121.com/blog/astro-css-hover-lift-glint-glassmorphism-accent-glow/
- Published: 2026-11-02T07:51:38+09:00
- Tags: CSS, Astro, Glassmorphism

---
## 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:

1. A translucent fill, such as `rgba(255, 255, 255, 0.1)`, so whatever sits behind the panel shows through.
2. `backdrop-filter: blur(10px)`, which blurs the part of the page behind the element rather than the element itself.
3. 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.

  A glassmorphism card on a dark gradient page: the backdrop shows through blurred, and under the pointer the card rises and its edge brightens

## 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.**

```css title="src/styles/global.css"
: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:

```css title="src/styles/global.css"
.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;
  }
}
```

> **HEADS UP**
>
> **What a media query is**
>
> A media query is CSS that applies only while the condition in its brackets is true. `(max-width: 600px)` means the screen is at most 600px wide, `(prefers-color-scheme: dark)` means the reader has dark mode on, and `(hover: hover)` means the main input can rest on an element without clicking. A mouse can; a touchscreen cannot.

- **`(hover: hover)`** tests the `hover` media feature, which asks whether the device can rest a pointer on an element.
  - `hover` means it can, as with a mouse or the trackpad below a laptop keyboard.
  - `none` means 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-preference`** tests the `prefers-reduced-motion` media feature, which asks whether the reader has turned on Reduce Motion in their operating system.
  - `reduce` means it is on, for example through Reduce Motion in the macOS or iOS accessibility settings.
  - `no-preference` means 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-visible`** gives 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-visible` sits 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.

*Image: The Load more button hovered in the light theme, with a blue border, a soft blue glow beneath it, and a faint blue band entering from its left edge*

*Load more, hovered in the light theme, where the accent band stays visible on white*

*Image: The Astro tag pill hovered in the dark theme, with a light-blue border and a blue glow beneath it on a near-black background*

*The Astro tag pill, hovered in the dark theme, where the accent is `#60a5fa`*

## The 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.

*Figure — OverflowClipsTooltip: Clipping a `::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 too*

**To 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.

```css title="src/styles/global.css"
.hover-glint {
  background-image: var(--hover-sheen);
  background-size: 300% 100%;
  background-repeat: no-repeat;
  background-position: 100% 0;
}
```

*Figure — SheenBandPosition: The button stays put and the background image slides under it. At `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 right*

With 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`.

> **CAREFUL**
>
> **`background` resets every sub-property it does not set**
>
> `background` is a shorthand that sets every background sub-property at once, including `background-color`, `background-image` and `background-position`. Writing only `background: var(--bg-2)` resets everything except the colour to its default, so `background-image` becomes `none` and the band disappears.

## 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`.**

> **HEADS UP**
>
> **How specificity is counted**
>
> When two rules set the same property, the browser applies the one with higher specificity. Specificity is written as (IDs, classes/attributes/pseudo-classes, elements) and compared from the left. `.hover-glint:hover` has one class and one pseudo-class, so it is (0,2,0). `.social a` becomes `.social[data-astro-cid-…] a[data-astro-cid-…]` after Astro scopes it: one class, two attributes and one element, so (0,3,1), which beats (0,2,0).

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`:

```css title="src/components/SiteFooter.astro"
.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.

> **HEADS UP**
>
> **How Pagefind raises its specificity**
>
> `:is()` matches any of the selectors listed in its brackets, and takes the specificity of the heaviest one. `#\#` is an ID selector, but no element on the page has the id `#`, so it is only there to add specificity. `*` matches every element, so `:is(*, #\#)` still selects everything while counting as one ID.

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:**

```css title="src/styles/global.css"
@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:

1. **A component that gets the effect sets `background-color`, never `background`**, or the shorthand deletes the band.
2. **A component whose scoped rule outranks or ties with the class sets its own accent border on both `:hover` and `:focus-visible`.**

## References

- [MDN: backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter)
- [MDN: Using media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Using)
- [MDN: the hover media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover)
- [MDN: prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
- [WCAG 2.2 Understanding 2.3.3: Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html)
- [MDN: :is() and how its specificity is calculated](https://developer.mozilla.org/en-US/docs/Web/CSS/:is)
- [MDN: Shorthand properties, and how they reset the sub-properties they leave out](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Cascade/Shorthand_properties)
- [Astro docs: scoped styles](https://docs.astro.build/en/guides/styling/#scoped-styles)
- [Interaction Design Foundation: what glassmorphism is](https://ixdf.org/literature/topics/glassmorphism)
