Glassy Button with SVG Filters & GSAP in Webflow

Intermediate 27:13 webflowsvg-filtergsaphoverglassmorphism

Build a glassy crystal button in Webflow that refracts the page behind it with an SVG filter, then sharpens from soft glass to crisp ice on hover with GSAP.

Key takeaways

  • The glass look isn't a CSS blur — it's an SVG filter applied through backdrop-filter, so the button actually refracts whatever scrolls behind it instead of just frosting it.
  • A displacement map is the secret ingredient: it's a texture the filter reads like a topographic map to decide how far to push and pull each pixel, which is why the effect looks like real glass and not a flat blur.
  • The whole thing is driven by three filter primitives — feImage loads the map, feGaussianBlur softens the button, and feDisplacementMap bends it — and its scale parameter alone takes you from gentle ripples to funhouse-mirror chaos.
  • The script finds your button by the fc-glass-button attribute, not a class name, so you can name your classes anything you like and the effect still hooks up.
  • GSAP animates the same filter values on hover, and because it also fires on keyboard focus, the crystal transition stays fully accessible instead of being a mouse-only flourish.

Video chapters

  1. 00:00 Intro
  2. 01:47 Analyzing the structure of the page
  3. 04:16 Creating the button
  4. 07:37 Adding the SVG filter
  5. 08:53 Linking the SVG filter to the button
  6. 13:10 How the SVG filter works and how to customize it
  7. 19:00 Animating the button with GSAP
  8. 23:38 Bonus examples
  9. 24:18 Multiple button instances or different pages
  10. 26:00 Outro

Build a glassy crystal button in Webflow that actually refracts the page behind it, then comes to life on hover — softening from frosted glass to crisp ice with a small nudge of GSAP. The trick isn’t a CSS blur; it’s a real SVG filter wired to the button through backdrop-filter, so whatever scrolls underneath bends and ripples through the glass.

What makes it worth the setup is how physical it feels. Because the filter reads the actual content behind the button, the effect changes as you scroll, and the hover animation gives it a tactile little lift. It looks like a lot of moving parts, but underneath it’s three SVG filter primitives, a few lines of JavaScript to feed it a texture, and a tiny GSAP timeline.

And none of it is locked to my class names. The script hooks onto an attribute, so you can name things however you like and still get the effect for free.

How it works

The glass look comes from an SVG filter, not a CSS blur. You paste the filter markup into an Embed element, give it display: none, and move it to the very top of the <body>. That placement matters: put the filter at the top so it’s global and always available — drop it lower and some browsers scope it locally or skip it, the same way a CSS variable belongs in :root. The filter carries id="glass", and the button references it with a custom property, backdrop-filter: url(#glass). That single line is the connection: the button grabs its filter source from the element with that ID.

<!-- 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>

Inside it, three primitives do the work. feImage loads the displacement map — a texture that acts like a topographic map, where lighter and darker areas tell the filter how far to push or pull each pixel — and outputs a result the rest of the chain can read. feGaussianBlur softens the button first; its stdDeviation controls how sharp or cloudy the glass looks. Then the star, feDisplacementMap, takes the blurred button and the map together and shifts the pixels — its 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.

The detail that makes those numbers make sense is primitiveUnits="objectBoundingBox": every value inside the filter is a fraction of the button’s own box, not a pixel count. That’s why stdDeviation="0.02" and scale="0.8" look tiny but 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 for a small and a large one. Push scale past ~1.4 and the glass reads as shattered rather than refracted.

Notice too that feImage has no href in the markup — that’s deliberate, and it’s the one thing the markup alone can’t do. The displacement map is an external image, so a short script fetches it and feeds it in. Without that, the filter knows it should distort but has no texture to distort with:

<!-- Embed at the bottom of the body: load the map + clone the filter per button -->
<script>
  document.addEventListener('DOMContentLoaded', async () => {
    // ===== 1) LOAD DISPLACEMENT MAP INTO <feImage> ===================
    const MAP_URL = "https://essykings.github.io/JavaScript/map.png"

    const template = document.getElementById('glass')
    const feImage  = template?.querySelector('feImage')
    const btns     = document.querySelectorAll('[fc-glass-button]')

    if (!template || !feImage) {
      console.warn("[glass] Filter template or <feImage> not found. Ensure #glass is in the DOM.")
      return
    }
    if (!btns.length) return // no buttons on page

    try {
      // Fetch the map image, convert to blob, and set it as <feImage> source
      const res    = await fetch(MAP_URL)
      const blob   = await res.blob()
      const objURL = URL.createObjectURL(blob)

      // SVG2 prefers `href`; some browsers still look for `xlink:href`
      feImage.setAttribute('href', objURL)
      feImage.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', objURL)

      // Keep a global reference to reuse in clones
      window.__glassMapHref = objURL
      // Revoke only when the page is closed
      window.addEventListener('beforeunload', () => URL.revokeObjectURL(objURL))
    } catch (err) {
      console.error("[glass] Failed to load displacement map:", err)
    }

    // ===== 1b) CLONE THE FILTER FOR EACH BUTTON ======================
    // Store references to <feGaussianBlur> and <feDisplacementMap> per button
    const mapHref =
      feImage.getAttribute('href') ||
      feImage.getAttributeNS('http://www.w3.org/1999/xlink','href') ||
      (feImage.href && feImage.href.baseVal) ||
      window.__glassMapHref || null

    const glassNodes = new WeakMap()
    btns.forEach((btn, i) => {
      const clone = template.cloneNode(true)
      const id = `glass-${i+1}`
      clone.id = id
      template.parentNode.appendChild(clone)

      // Ensure the cloned filter points to the same displacement map
      const img = clone.querySelector('feImage')
      if (mapHref && img) {
        img.setAttribute('href', mapHref)
        img.setAttributeNS('http://www.w3.org/1999/xlink','xlink:href', mapHref)
      }

      // Apply this unique filter to the button via backdrop-filter
      btn.style.backdropFilter = `url(#${id})`

      // Save blur + displacement nodes for this button
      glassNodes.set(btn, {
        blur: clone.querySelector('feGaussianBlur'),
        disp: clone.querySelector('feDisplacementMap')
      })
    })

    // Expose references globally for the second script
    window.__glassNodes = glassNodes
    document.dispatchEvent(new CustomEvent('glass:filters-ready'))
  })
</script>

That script does more than load an image, and the extra part is what makes multiple buttons possible. A single filter animated by two buttons would fight itself — hovering one would warp both — so the script clones the filter per button, gives each clone its own id (glass-1, glass-2, …), and repoints that button’s backdrop-filter 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 quietly swaps it for the clone. It keeps the blur and displacement nodes of each clone in a WeakMap and fires a glass:filters-ready event when it’s done, which is how the animation script knows what to grab. Crucially, it finds buttons by the fc-glass-button attribute (present, no value needed), not by a class, so your naming convention is completely irrelevant.

The hover comes last, and it’s GSAP:

<!-- Embed at the bottom of the body: hover animation (glass -> ice) -->
<script>
  document.addEventListener('DOMContentLoaded', () => {
    // ===== 2) HOVER ANIMATION (GLASS -> ICE) =========================
    // Edit only these constants to change the feel of the effect
    const HOVER_SCALE        = 1.40  // on-hover displacement intensity
    const HOVER_TRANSF_SCALE = 1.015 // scale transform applied to the button
    const HOVER_BLUR         = 0.00  // on-hover softness (lower = sharper/ice-like)
    const DURATION           = 0.30  // animation duration (0.2–0.4)
    const EASE               = 'power1.out' // GSAP easing

    const btns = document.querySelectorAll('[fc-glass-button]')
    if (!btns.length) return

    const setup = () => {
      const map = window.__glassNodes
      if (!map) return false

      btns.forEach((btn) => {
        const nodes = map.get(btn)
        if (!nodes?.blur || !nodes?.disp) return

        const { blur, disp } = nodes

        // Build a per-button timeline
        const tl = gsap.timeline({ paused: true, defaults: { duration: DURATION, ease: EASE }})
          .to(disp, { attr: { scale: HOVER_SCALE } }, 0)       // stronger displacement
          .to(blur, { attr: { stdDeviation: HOVER_BLUR } }, 0) // less blur = icy/crisp
          .to(btn,  { y: -1, scale: HOVER_TRANSF_SCALE }, 0)   // tiny physical feedback

        // Pointer + keyboard accessibility
        btn.addEventListener('pointerenter', () => tl.play())
        btn.addEventListener('pointerleave', () => tl.reverse())
        btn.addEventListener('focus',        () => tl.play())
        btn.addEventListener('blur',         () => tl.reverse())
      })
      return true
    }

    // Run immediately if filters are ready, otherwise wait for event
    if (!setup()) {
      document.addEventListener('glass:filters-ready', setup, { once: true })
    }
  })
</script>

A tiny timeline animates the displacement and blur from the resting “soft glass” state to a “crisp ice” state, nudging the button up 1px and scaling it slightly for tactile feedback. Note the direction of travel: displacement goes up (0.8 → HOVER_SCALE 1.40) while blur goes down (0.02 → HOVER_BLUR 0) — more distortion, less haze, which is exactly what reads as glass hardening into ice. You tune it with the five constants at the top: HOVER_SCALE (~1.05–1.25 for a classy ripple, 1.40+ for jagged broken-crystal edges), HOVER_TRANSF_SCALE (keep it modest — 1.015 is already noticeable), HOVER_BLUR (0 for crisp, ~0.03 for a softer landing), DURATION (0.2s snappy, 0.4s elegant), and EASE (e.g. power1.out, power2.out, expo.out). The same animation runs on keyboard focus and reverses on blur, so keyboard users get the exact same experience — the effect stays accessible, not mouse-only.

How to use it

  1. Clone the project. Grab the Webflow cloneable — it ships with the button, the SVG filter markup, the displacement-map loader, and the GSAP hover snippet, all commented.

  2. Give the button contrast to refract. The effect only shines when there’s content behind it. In the demo the button sits fixed and centered while a full-width background image scrolls underneath, so you can watch the filter change with the page. Any rich, scrollable background works.

  3. Style the glass base. Make the button background transparent with a subtle white linear gradient (roughly 135deg, white at ~20% → ~8% → ~3% opacity), a pill border-radius, and a 1px white border at ~30% opacity. This frosted base is what the filter refracts — tweak or drop it freely; it changes the look, not the filter.

  4. Add the filter. Paste the filter markup above into an Embed, set it to display: none, and move that Embed to the top of the body. It has id="glass".

  5. Link the filter to the button. On the button, add the custom property backdrop-filter with the value url(#glass). You’ll see a rough glass effect appear immediately.

  6. Load the displacement map. Paste the loader script into a second Embed (display: none), placed at the bottom of the body. It feeds the map image into the filter so it has texture to bend, then clones the filter once per button. The map is fetched from an external URL (MAP_URL at the top of the script) — swap it for your own hosted copy if you’d rather not depend on someone else’s server.

  7. Tag the button. Add the attribute fc-glass-button to your button — no value needed — so the script can find it regardless of its class name.

  8. Add the hover and turn on GSAP. Paste the GSAP snippet, then flip GSAP on in your project settings — the code does nothing until the library loads. Tweak HOVER_SCALE, HOVER_BLUR, DURATION, and EASE to taste.

  9. Reuse it. For several buttons on one page, duplicate the button — the loader already gives each one its own cloned filter, so they animate independently. For other pages, turn the filter and script into components (edits then propagate everywhere), or place the script alone in the site-wide footer code and drop a button wherever you need it.

Resources

In the knowledge base

Reference pages derived from this lesson — the same material reorganised by concept, so you can look one thing up without rewatching. In English.

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

  • Verified identifiers

    Every exact attribute name, class name, variable name, SVG id and selector used across the GSAP-in-Webflow corpus, each traced to the cloneable it was copied from; copy these verbatim and never reconstruct one from memory, because a wrong identifier fails silently.

Frequently asked questions

How do I make a glass button that distorts the background in Webflow?
Real background distortion comes from an SVG filter, not a CSS blur. You embed a filter that uses feDisplacementMap and reference it from the button with backdrop-filter: url(#glass) — the filter then bends whatever sits behind the button, so scrolling content ripples through it like glass.
What is a displacement map and why does the effect need one?
A displacement map is a grayscale or colored texture the filter reads like a topographic map: lighter and darker areas tell it how much to push or pull each pixel. Without one the filter has no idea how to bend the light, so the glass looks flat — the map is what gives it depth and movement.
Why is my SVG filter not working when I add it in Webflow?
Two things usually cause it. Place the filter embed at the very top of the body so it's globally available — dropped further down, some browsers scope it locally and ignore it. And make sure the button carries the fc-glass-button attribute, because the loader script targets that attribute rather than a class.
Do I need GSAP for the hover animation on the button?
Yes — the hover transition from soft glass to crisp ice is a GSAP timeline that animates the filter's blur and displacement. Remember to switch GSAP on in Webflow's project settings; the code runs but nothing moves until the library itself is loaded.
Can I use the same glassy button on multiple pages in Webflow?
You can. The loader script already handles several buttons on one page, so you just duplicate the button. To reuse it across pages, turn the filter and the script into components (so edits propagate everywhere), or drop the script alone into the site-wide footer code and place a button on any page.

Also part of these courses