Crea in Webflow un bottone di cristallo che rifrange davvero la pagina dietro di sé e poi prende vita all’hover, passando da vetro smerigliato a ghiaccio nitido con un pizzico di GSAP. Il trucco non è una sfocatura CSS: è un vero filtro SVG collegato al bottone tramite backdrop-filter, così tutto ciò che scorre sotto si piega e si increspa attraverso il vetro.
Quello che lo rende speciale è quanto sembra fisico. Dato che il filtro legge il contenuto reale dietro il bottone, l’effetto cambia mentre scorri, e l’animazione all’hover gli regala una piccola spinta tattile. Sembrano tanti pezzi in movimento, ma sotto ci sono tre primitive di un filtro SVG, poche righe di JavaScript per dargli una texture e una minuscola timeline GSAP.
E niente è legato ai miei nomi di classe. Lo script si aggancia a un attributo, quindi puoi chiamare le cose come preferisci e avere lo stesso effetto senza pensarci.
Come funziona
L’aspetto vetro nasce da un filtro SVG, non da una sfocatura CSS. Incolli il markup del filtro dentro un elemento Embed, gli dai display: none e lo sposti proprio in cima al <body>. La posizione conta: metti il filtro in alto così è globale e sempre disponibile — più in basso alcuni browser lo limitano allo scope locale o lo saltano, un po’ come una variabile CSS che va messa in :root. Il filtro ha id="glass" e il bottone lo richiama con una proprietà personalizzata, backdrop-filter: url(#glass). È quella riga a creare il collegamento: il bottone prende la sorgente del filtro dall’elemento con quell’ID.
<!-- Embed in cima al 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>
Dentro lavorano tre primitive. feImage carica la mappa di spostamento — una texture che funziona come una mappa topografica, dove le zone più chiare e più scure dicono al filtro quanto spingere o tirare ogni pixel — e produce un risultato che il resto della catena può leggere. feGaussianBlur ammorbidisce prima il bottone; il suo stdDeviation regola quanto il vetro appare nitido o nebbioso. Poi la star, feDisplacementMap, prende insieme il bottone sfocato e la mappa e sposta i pixel: il suo scale è la manopola dell’intensità. La regione del filtro è volutamente sovradimensionata (x="-1" y="-1" width="3" height="3") così ai bordi non si taglia nulla.
Il dettaglio che rende sensati quei numeri è primitiveUnits="objectBoundingBox": ogni valore dentro il filtro è una frazione del box del bottone, non un numero di pixel. Ecco perché stdDeviation="0.02" e scale="0.8" sembrano minuscoli ma pesano tanto — 0.8 significa che lo spostamento può spingere un pixel per quasi tutta la larghezza del bottone. Significa anche che l’effetto si adatta da solo alla dimensione del bottone, senza bisogno di numeri diversi per uno piccolo e uno grande. Spingi scale oltre ~1.4 e il vetro si legge come frantumato invece che rifrangente.
Nota anche che feImage non ha un href nel markup — è voluto, ed è l’unica cosa che il solo markup non può fare. La mappa di spostamento è un’immagine esterna, quindi un breve script la recupera e la passa dentro. Senza, il filtro sa di dover distorcere ma non ha nessuna texture con cui farlo:
<!-- Embed in fondo al body: carica la mappa + clona il filtro per ogni bottone -->
<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>
Quello script fa più che caricare un’immagine, e la parte in più è ciò che rende possibili più bottoni. Un filtro unico animato da due bottoni litigherebbe con sé stesso — passare su uno deformerebbe entrambi — quindi lo script clona il filtro per ogni bottone, dà a ogni clone il suo id (glass-1, glass-2, …) e ripunta il backdrop-filter di quel bottone sulla propria copia. Lo url(#glass) che imposti a mano in Webflow è in realtà l’anteprima nel Designer; sulla pagina live lo script lo sostituisce in silenzio con il clone. Tiene i nodi di blur e displacement di ogni clone in una WeakMap e lancia un evento glass:filters-ready quando ha finito: è così che lo script dell’animazione sa cosa prendere. Fondamentale: trova i bottoni tramite l’attributo fc-glass-button (presente, senza valore), non tramite una classe, quindi la tua convenzione di naming è del tutto irrilevante.
L’hover arriva per ultimo, ed è GSAP:
<!-- Embed in fondo al body: animazione hover (vetro -> ghiaccio) -->
<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>
Una piccola timeline anima lo spostamento e la sfocatura dallo stato di riposo “vetro morbido” allo stato “ghiaccio nitido”, sollevando il bottone di 1px e ingrandendolo un filo per un feedback tattile. Nota la direzione del movimento: lo spostamento sale (0.8 → HOVER_SCALE 1.40) mentre la sfocatura scende (0.02 → HOVER_BLUR 0) — più distorsione, meno foschia, che è esattamente ciò che si legge come vetro che indurisce in ghiaccio. Lo regoli con le cinque costanti in cima: HOVER_SCALE (circa 1.05–1.25 per un’increspatura elegante, 1.40+ per bordi di cristallo spezzato), HOVER_TRANSF_SCALE (tienila contenuta — 1.015 si nota già), HOVER_BLUR (0 per nitido, ~0.03 per un atterraggio più morbido), DURATION (0.2s scattante, 0.4s elegante) ed EASE (per esempio power1.out, power2.out, expo.out). La stessa animazione parte anche sul focus da tastiera e si inverte al blur, così chi naviga da tastiera vive la stessa esperienza — l’effetto resta accessibile, non solo per il mouse.
Come usarlo
-
Clona il progetto. Prendi il cloneable Webflow: include il bottone, il markup del filtro SVG, lo script che carica la mappa di spostamento e lo snippet GSAP per l’hover, tutto commentato.
-
Dai al bottone qualcosa da rifrangere. L’effetto brilla solo quando c’è contenuto dietro. Nella demo il bottone è
fixede centrato mentre un’immagine di sfondo a tutta larghezza scorre sotto, così vedi il filtro cambiare con la pagina. Va bene qualsiasi sfondo ricco e scorrevole. -
Stila la base di vetro. Rendi lo sfondo del bottone trasparente con un gradiente lineare bianco e delicato (circa
135deg, bianco al ~20% → ~8% → ~3% di opacità), unborder-radiusa pillola e un bordo bianco di 1px a ~30% di opacità. È questa base smerigliata che il filtro rifrange — modificala o toglila pure: cambia l’aspetto, non il filtro. -
Aggiungi il filtro. Incolla il markup del filtro qui sopra in un Embed, mettilo a
display: nonee sposta quell’Embed in cima al body. Haid="glass". -
Collega il filtro al bottone. Sul bottone aggiungi la proprietà personalizzata
backdrop-filtercon valoreurl(#glass). Vedrai comparire subito un primo effetto vetro. -
Carica la mappa di spostamento. Incolla lo script nel secondo Embed (
display: none), posizionato in fondo al body. Passa l’immagine-mappa al filtro così ha una texture da piegare, poi clona il filtro una volta per bottone. La mappa viene scaricata da un URL esterno (MAP_URLin cima allo script) — sostituiscilo con una tua copia ospitata se preferisci non dipendere dal server di qualcun altro. -
Marca il bottone. Aggiungi l’attributo
fc-glass-buttonal bottone — senza valore — così lo script lo trova a prescindere dal nome della classe. -
Aggiungi l’hover e attiva GSAP. Incolla lo snippet GSAP, poi attiva GSAP nelle impostazioni del progetto — il codice non fa nulla finché la libreria non è caricata. Ritocca
HOVER_SCALE,HOVER_BLUR,DURATIONedEASEa piacere. -
Riusalo. Per più bottoni sulla stessa pagina, duplica il bottone: lo script dà già a ognuno il proprio filtro clonato, così si animano in modo indipendente. Per altre pagine, trasforma il filtro e lo script in componenti (le modifiche si propagano ovunque), oppure metti il solo script nel footer code del sito e piazza un bottone dove serve.