GSAP in Webflow · Recipes

Build a glass button that refracts the page behind it

A real SVG displacement-map filter wired to the button through backdrop-filter bends whatever scrolls underneath, and a tiny GSAP timeline hardens it from frosted glass to crisp ice on hover — but SVG filter references in backdrop-filter are Chromium-only, so Safari and Firefox visitors see the plain frosted base and none of the refraction.

  • advanced
  • Last verified: July 27, 2026
  • Verified against: cloneable

Read this before you build it (checked 2026-07-27)

SVG filter references in backdrop-filter are Chromium-only. backdrop-filter itself is Baseline, but that covers the standard filter functions like blur()not url(#some-filter). Firefox does not support it, and WebKit has an open bug for exactly this case (feDisplacementMap via backdrop-filter).

BrowserWhat the visitor gets
Chromium (Chrome, Edge, Brave, Opera)The full effect
Safari — including every browser on iOSThe frosted base only. No refraction
FirefoxThe frosted base only. No refraction

How it degrades. The button keeps its own styling — transparent background with a white gradient, pill radius, thin white border — so it still looks like a deliberate glass button. The GSAP hover still nudges it up and scales it, because those animate the button itself. What is missing is the entire refraction, which is the reason to build it.

Say this to anyone who asks for this effect. It is not a progressive-enhancement footnote: on iOS — where every browser uses WebKit — the showpiece simply does not appear. That may be fine for a Chromium-heavy audience or a portfolio piece, and wrong for a marketing hero. Let the person decide with the facts.

The rule

The glass look is an SVG filter, not a CSS blur. Paste the filter into an Embed, set it display: none, and move it to the very top of the <body> — placement matters, because lower down some browsers scope it locally or skip it, the same way a CSS variable belongs in :root.

<!-- Embed at the very top of the body, display: none -->
<svg>
  <filter id="glass"
          x="-1" y="-1" width="3" height="3"
          filterUnits="objectBoundingBox"
          primitiveUnits="objectBoundingBox">
    <feImage x="-0.5" y="-0.5" width="2" height="2"
             preserveAspectRatio="none"
             result="map" />
    <feGaussianBlur in="SourceGraphic" stdDeviation="0.02" result="blur"/>
    <feDisplacementMap in="blur" in2="map"
                       scale="0.8" xChannelSelector="R" yChannelSelector="G"/>
  </filter>
</svg>

Then on the button, a custom property: backdrop-filter: url(#glass).

The three primitives

PrimitiveJob
feImageloads the displacement map — a texture whose light and dark areas say how far to push each pixel
feGaussianBlursoftens the button first; stdDeviation is sharp-versus-cloudy
feDisplacementMaptakes the blurred button and the map together and shifts pixels; scale is the intensity dial

The filter region is deliberately oversized (x="-1" y="-1" width="3" height="3") so nothing clips at the edges.

primitiveUnits="objectBoundingBox" is the detail that makes the numbers make sense. Every value inside the filter is a fraction of the button’s own box, not a pixel count. That is why stdDeviation="0.02" and scale="0.8" look tiny and hit hard — 0.8 means the displacement can shove a pixel by most of the button’s width. It also means the effect scales automatically with the button instead of needing different numbers per size.

This is the one identifier-class error the code-gap pass caught inside the GSAP corpus: plausible “1–5” and “20+” pixel ranges were wrong by orders of magnitude. Push scale past ~1.4 and the glass reads as shattered rather than refracted.

Why a script is unavoidable

feImage has no href in the markup — deliberately. The displacement map is an external image, so a script fetches it and feeds it in. Without that, the filter knows it should distort but has no texture to distort with.

The loader does a second, less obvious job: it clones the filter once per button. One filter animated by two buttons would fight itself — hovering one would warp both — so each clone gets its own id (glass-1, glass-2, …) and that button’s backdrop-filter is repointed at its own copy. The url(#glass) you set by hand in Webflow is really the Designer-time preview; on the live page the script swaps it.

It keeps each clone’s blur and displacement nodes in a WeakMap and fires a glass:filters-ready event when done, which is how the animation script knows what to grab. Buttons are found by the fc-glass-button attribute (present, no value), never by class.

MAP_URL at the top of the loader points at an external server. Swap it for your own hosted copy rather than depending on someone else’s — if that host goes away, every glass button on your site loses its texture.

The hover: glass → ice

A tiny timeline, and the direction of travel is the whole trick: displacement goes up while blur goes down. More distortion, less haze — which is exactly what reads as glass hardening into ice.

ConstantDefaultRange
HOVER_SCALE1.401.05–1.25 classy ripple · 1.40+ jagged broken-crystal
HOVER_TRANSF_SCALE1.015keep modest — this is already noticeable
HOVER_BLUR0.000 crisp · ~0.03 softer landing
DURATION0.300.2 snappy · 0.4 elegant
EASEpower1.outalso power2.out, expo.out

It also nudges the button y: -1 and scales it slightly, for tactile feedback. The same animation runs on focus and reverses on blur, so keyboard users get the identical experience — keep both event pairs if you rewrite it.

Give it something to refract

The effect only shows when there is content behind the button. In the demo the button is fixed and centred while a full-width background image scrolls underneath, so the refraction changes as you scroll. Any rich, scrollable background works; over a flat colour there is nothing to bend and the whole thing is invisible even in Chromium.

Style the base with a transparent background, a subtle white linear gradient (~135deg, white at ~20% → ~8% → ~3% opacity), a pill border-radius, and a 1px white border at ~30% opacity. This base is what non-Chromium visitors see, so make it good on its own.

Verify

Test in Safari first, not last — that is where you find out what most visitors get. Then in Chromium: scroll the background behind the button and confirm the refraction changes; hover to confirm it hardens; Tab to it to confirm focus does the same. With several buttons, hover one and confirm the others don’t move — if they do, the per-button filter cloning isn’t running.

Sources