36 / 36
All 36 in 36 Tailwind CSS Button Hover Effects
- 01Tailwind Magnetic Ripple Attraction Button Hover Effect
- 02Tailwind Cyberpunk Holographic Glitch Grid Button
- 03Tailwind Frosted Glassmorphism Shimmer Glow Button
- 04Tailwind Organic Blob Morph Button Hover Effect
- 05Tailwind Dual-Line Intersection Border Draw Button
- 06Tailwind Isometric Tactile 3D Depress Button
- 07Tailwind Infinite Sliding Kinetic Marquee Button
- 08Tailwind Chroma Conic Gradient Border Spin Button
- 09Tailwind Minimalist Border & Outline Drawing Hover Effects
- 10Tailwind Modern 3D Skeuomorphic Button Press Effects
- 11Tailwind Neo-Brutalist & Cyberpunk Button Glow Effects
- 12Tailwind Sliding Background Color Wipe Button Effects
- 13Tailwind Liquid Fill & Organic Morphing Button Effects
- 14Tailwind Kinetic Text Flip & Icon Reveal Button Effects
- 15Tailwind Liquid Wave Fill Button Hover Effect
- 16Tailwind Magnetic Border Drift Button
- 17Tailwind Glitch Slice Button Hover Effect
- 18Tailwind Neon Pulse Glow Button Hover Effect
- 19Tailwind Split Reveal Curtain Button Effect
- 20Tailwind Diagonal Shutter Sweep Button Effect
- 21Tailwind Elastic Lift Spring Button Hover Effect
- 22Tailwind Center Underline Draw Link Button Effects
- 23Tailwind 3D Flip Card Button Hover Effect
- 24Tailwind Particle Burst Confetti Button Effect
- 25Tailwind Slide Doors Closing Panel Button Effect
- 26Tailwind Typewriter Retype Terminal Button Effect
- 27Tailwind Hard Shadow Shift Flat Button Effect
- 28Tailwind Material Ripple Wave Button Effect
- 29Tailwind Rainbow Gradient Border Spin Button
- 30Tailwind Morph Shape Silhouette Button Effects
- 31Tailwind Icon Slide In Directional Button Effect
- 32Tailwind Cursor Spotlight Glow Button
- 33Tailwind Stagger Fill Equaliser Button Effect
- 34Tailwind Sunburst Expand Radiant Button Effect
- 35Tailwind 3D Press Plunger Key Button Effect
- 36Tailwind Add-to-Cart Liquid Glass Ripple Button
Tailwind Add-to-Cart Liquid Glass Ripple Button
A Liquid Glass 'Add to Cart' CTA that ripples like a disturbed pool on click — a real SVG displacement map radiates from the pointer and warps the frosted surface, then decays. Includes a success morph, an aria-live status and a High-Performance toggle that swaps the GPU-heavy warp for a pure-CSS ripple.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tbh-36 w-full min-h-svh grid place-items-center p-6 font-tbh36-sans bg-[conic-gradient(from_200deg_at_40%_60%,var(--color-tbh36-g1),var(--color-tbh36-g2),var(--color-tbh36-g3),var(--color-tbh36-g1))] bg-[length:220%_220%] animate-tbh36-bg motion-reduce:animate-none" aria-label="Add to cart ripple glass button demo">
<svg aria-hidden="true" width="0" height="0" class="absolute">
<filter id="tbh36-ripple">
<feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="2" seed="3" result="n">
<animate attributeName="baseFrequency" dur="6s" values="0.02;0.035;0.02" repeatCount="indefinite"></animate>
</feTurbulence>
<feDisplacementMap id="tbh36-disp" in="SourceGraphic" in2="n" scale="0" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
</filter>
</svg>
<div id="tbh-36-stage" data-perf="high" class="group/stage grid justify-items-center gap-4">
<button id="tbh-36-btn" type="button" class="group/btn relative isolate overflow-hidden min-h-[52px] px-[30px] cursor-pointer rounded-full border border-white/45 text-base font-bold text-white bg-[color-mix(in_oklab,white_16%,transparent)] shadow-[inset_0_1px_0_oklch(1_0_0/0.6),0_18px_40px_-18px_oklch(0_0_0/0.7)] backdrop-blur-[16px] backdrop-saturate-[1.8] transition-[box-shadow,background-color,border-color] duration-300 hover:shadow-[inset_0_1px_0_oklch(1_0_0/0.7),0_22px_46px_-18px_oklch(0_0_0/0.7)] focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-[3px] data-done:bg-[color-mix(in_oklab,var(--color-tbh36-accent)_70%,transparent)] data-done:border-transparent">
<span aria-hidden="true" class="absolute inset-0 z-0 bg-[radial-gradient(140px_140px_at_var(--mx,50%)_var(--my,50%),oklch(1_0_0/0.4),transparent_60%)] group-data-[perf=high]/stage:[filter:url(#tbh36-ripple)] group-data-[perf=css]/stage:group-[.is-ripple]/btn:animate-tbh36-css motion-reduce:[filter:none]"></span>
<span class="relative z-[1] inline-flex items-center gap-[9px] transition-[opacity,scale] duration-300 group-data-done/btn:scale-[1.04]">
<span class="inline-flex items-center gap-[9px] group-data-done/btn:hidden">
<span aria-hidden="true" class="text-[17px]">🛒</span>Add to Cart · $89
</span>
<span class="hidden items-center gap-[9px] group-data-done/btn:inline-flex">
<span aria-hidden="true">✓</span>Added to Cart
</span>
</span>
</button>
<p id="tbh-36-status" aria-live="polite" class="min-h-[1.2em] text-[13px] font-semibold text-white [text-shadow:0_1px_6px_oklch(0_0_0/0.3)]"></p>
<label class="flex items-center gap-[7px] text-[12.5px] text-white/85 cursor-pointer">
<input id="tbh-36-perf" type="checkbox" class="accent-tbh36-accent"> High-Performance Mode (CSS ripple)
</label>
</div>
</section><section class="tbh-36 w-full min-h-svh grid place-items-center p-6 font-tbh36-sans bg-[conic-gradient(from_200deg_at_40%_60%,var(--color-tbh36-g1),var(--color-tbh36-g2),var(--color-tbh36-g3),var(--color-tbh36-g1))] bg-[length:220%_220%] animate-tbh36-bg motion-reduce:animate-none" aria-label="Add to cart ripple glass button demo">
<svg aria-hidden="true" width="0" height="0" class="absolute">
<filter id="tbh36-ripple">
<feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="2" seed="3" result="n">
<animate attributeName="baseFrequency" dur="6s" values="0.02;0.035;0.02" repeatCount="indefinite"></animate>
</feTurbulence>
<feDisplacementMap id="tbh36-disp" in="SourceGraphic" in2="n" scale="0" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
</filter>
</svg>
<div id="tbh-36-stage" data-perf="high" class="group/stage grid justify-items-center gap-4">
<button id="tbh-36-btn" type="button" class="group/btn relative isolate overflow-hidden min-h-[52px] px-[30px] cursor-pointer rounded-full border border-white/45 text-base font-bold text-white bg-[color-mix(in_oklab,white_16%,transparent)] shadow-[inset_0_1px_0_oklch(1_0_0/0.6),0_18px_40px_-18px_oklch(0_0_0/0.7)] backdrop-blur-[16px] backdrop-saturate-[1.8] transition-[box-shadow,background-color,border-color] duration-300 hover:shadow-[inset_0_1px_0_oklch(1_0_0/0.7),0_22px_46px_-18px_oklch(0_0_0/0.7)] focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-[3px] data-done:bg-[color-mix(in_oklab,var(--color-tbh36-accent)_70%,transparent)] data-done:border-transparent">
<span aria-hidden="true" class="absolute inset-0 z-0 bg-[radial-gradient(140px_140px_at_var(--mx,50%)_var(--my,50%),oklch(1_0_0/0.4),transparent_60%)] group-data-[perf=high]/stage:[filter:url(#tbh36-ripple)] group-data-[perf=css]/stage:group-[.is-ripple]/btn:animate-tbh36-css motion-reduce:[filter:none]"></span>
<span class="relative z-[1] inline-flex items-center gap-[9px] transition-[opacity,scale] duration-300 group-data-done/btn:scale-[1.04]">
<span class="inline-flex items-center gap-[9px] group-data-done/btn:hidden">
<span aria-hidden="true" class="text-[17px]">🛒</span>Add to Cart · $89
</span>
<span class="hidden items-center gap-[9px] group-data-done/btn:inline-flex">
<span aria-hidden="true">✓</span>Added to Cart
</span>
</span>
</button>
<p id="tbh-36-status" aria-live="polite" class="min-h-[1.2em] text-[13px] font-semibold text-white [text-shadow:0_1px_6px_oklch(0_0_0/0.3)]"></p>
<label class="flex items-center gap-[7px] text-[12.5px] text-white/85 cursor-pointer">
<input id="tbh-36-perf" type="checkbox" class="accent-tbh36-accent"> High-Performance Mode (CSS ripple)
</label>
</div>
</section>@import "tailwindcss";
@theme {
--color-tbh36-accent: #4ec98d;
--color-tbh36-accent: oklch(0.68 0.18 155);
--color-tbh36-g1: #0ea5e9;
--color-tbh36-g1: oklch(0.7 0.15 230);
--color-tbh36-g2: #6366f1;
--color-tbh36-g2: oklch(0.58 0.22 275);
--color-tbh36-g3: #ec4899;
--color-tbh36-g3: oklch(0.65 0.24 350);
--font-tbh36-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tbh36-bg: tbh36-bg 20s ease-in-out infinite;
--animate-tbh36-css: tbh36-css 0.6s ease-out;
}
/* Stays CSS: the drifting stage gradient and the fallback ripple keyframes. */
@keyframes tbh36-bg {
50% {
background-position: 100% 100%;
}
}
@keyframes tbh36-css {
0% {
scale: 0.3;
opacity: 0.9;
}
100% {
scale: 2.4;
opacity: 0;
}
}
/* Stays CSS: Safari < 16.4 prefixed backdrop-filter. */
.tbh-36 button {
-webkit-backdrop-filter: blur(16px) saturate(180%);
}
.tbh-36 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-tbh36-accent: #4ec98d;
--color-tbh36-accent: oklch(0.68 0.18 155);
--color-tbh36-g1: #0ea5e9;
--color-tbh36-g1: oklch(0.7 0.15 230);
--color-tbh36-g2: #6366f1;
--color-tbh36-g2: oklch(0.58 0.22 275);
--color-tbh36-g3: #ec4899;
--color-tbh36-g3: oklch(0.65 0.24 350);
--font-tbh36-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tbh36-bg: tbh36-bg 20s ease-in-out infinite;
--animate-tbh36-css: tbh36-css 0.6s ease-out;
}
/* Stays CSS: the drifting stage gradient and the fallback ripple keyframes. */
@keyframes tbh36-bg {
50% {
background-position: 100% 100%;
}
}
@keyframes tbh36-css {
0% {
scale: 0.3;
opacity: 0.9;
}
100% {
scale: 2.4;
opacity: 0;
}
}
/* Stays CSS: Safari < 16.4 prefixed backdrop-filter. */
.tbh-36 button {
-webkit-backdrop-filter: blur(16px) saturate(180%);
}
.tbh-36 {
width: 100%;
min-height: 100svh;
}(() => {
const stage = document.querySelector('#tbh-36-stage');
const btn = document.querySelector('#tbh-36-btn');
const disp = document.querySelector('#tbh36-disp');
const status = document.querySelector('#tbh-36-status');
const perf = document.querySelector('#tbh-36-perf');
if (!stage || !btn) return;
perf.addEventListener('change', () => { stage.dataset.perf = perf.checked ? 'css' : 'high'; });
const setPos = e => {
const r = btn.getBoundingClientRect();
btn.style.setProperty('--mx', (e.clientX - r.left) + 'px');
btn.style.setProperty('--my', (e.clientY - r.top) + 'px');
};
btn.addEventListener('pointermove', setPos);
let raf;
const rippleSVG = () => {
if (!disp) return;
let s = 26;
cancelAnimationFrame(raf);
const step = () => {
s -= 1.6;
if (s <= 0) { disp.setAttribute('scale', 0); return; }
disp.setAttribute('scale', s);
raf = requestAnimationFrame(step);
};
step();
};
let reset;
btn.addEventListener('click', e => {
setPos(e);
if (stage.dataset.perf === 'high') rippleSVG();
else { btn.classList.remove('is-ripple'); void btn.offsetWidth; btn.classList.add('is-ripple'); }
btn.dataset.done = '';
status.textContent = 'Added to cart — 1 item';
clearTimeout(reset);
reset = setTimeout(() => { delete btn.dataset.done; status.textContent = ''; }, 1600);
});
})();(() => {
const stage = document.querySelector('#tbh-36-stage');
const btn = document.querySelector('#tbh-36-btn');
const disp = document.querySelector('#tbh36-disp');
const status = document.querySelector('#tbh-36-status');
const perf = document.querySelector('#tbh-36-perf');
if (!stage || !btn) return;
perf.addEventListener('change', () => { stage.dataset.perf = perf.checked ? 'css' : 'high'; });
const setPos = e => {
const r = btn.getBoundingClientRect();
btn.style.setProperty('--mx', (e.clientX - r.left) + 'px');
btn.style.setProperty('--my', (e.clientY - r.top) + 'px');
};
btn.addEventListener('pointermove', setPos);
let raf;
const rippleSVG = () => {
if (!disp) return;
let s = 26;
cancelAnimationFrame(raf);
const step = () => {
s -= 1.6;
if (s <= 0) { disp.setAttribute('scale', 0); return; }
disp.setAttribute('scale', s);
raf = requestAnimationFrame(step);
};
step();
};
let reset;
btn.addEventListener('click', e => {
setPos(e);
if (stage.dataset.perf === 'high') rippleSVG();
else { btn.classList.remove('is-ripple'); void btn.offsetWidth; btn.classList.add('is-ripple'); }
btn.dataset.done = '';
status.textContent = 'Added to cart — 1 item';
clearTimeout(reset);
reset = setTimeout(() => { delete btn.dataset.done; status.textContent = ''; }, 1600);
});
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tbh36-accent: #4ec98d;
--color-tbh36-accent: oklch(0.68 0.18 155);
--color-tbh36-g1: #0ea5e9;
--color-tbh36-g1: oklch(0.7 0.15 230);
--color-tbh36-g2: #6366f1;
--color-tbh36-g2: oklch(0.58 0.22 275);
--color-tbh36-g3: #ec4899;
--color-tbh36-g3: oklch(0.65 0.24 350);
--font-tbh36-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tbh36-bg: tbh36-bg 20s ease-in-out infinite;
--animate-tbh36-css: tbh36-css 0.6s ease-out;
}
/* Stays CSS: the drifting stage gradient and the fallback ripple keyframes. */
@keyframes tbh36-bg {
50% {
background-position: 100% 100%;
}
}
@keyframes tbh36-css {
0% {
scale: 0.3;
opacity: 0.9;
}
100% {
scale: 2.4;
opacity: 0;
}
}
/* Stays CSS: Safari < 16.4 prefixed backdrop-filter. */
.tbh-36 button {
-webkit-backdrop-filter: blur(16px) saturate(180%);
}
.tbh-36 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-tbh36-accent: #4ec98d;
--color-tbh36-accent: oklch(0.68 0.18 155);
--color-tbh36-g1: #0ea5e9;
--color-tbh36-g1: oklch(0.7 0.15 230);
--color-tbh36-g2: #6366f1;
--color-tbh36-g2: oklch(0.58 0.22 275);
--color-tbh36-g3: #ec4899;
--color-tbh36-g3: oklch(0.65 0.24 350);
--font-tbh36-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tbh36-bg: tbh36-bg 20s ease-in-out infinite;
--animate-tbh36-css: tbh36-css 0.6s ease-out;
}
/* Stays CSS: the drifting stage gradient and the fallback ripple keyframes. */
@keyframes tbh36-bg {
50% {
background-position: 100% 100%;
}
}
@keyframes tbh36-css {
0% {
scale: 0.3;
opacity: 0.9;
}
100% {
scale: 2.4;
opacity: 0;
}
}
/* Stays CSS: Safari < 16.4 prefixed backdrop-filter. */
.tbh-36 button {
-webkit-backdrop-filter: blur(16px) saturate(180%);
}
.tbh-36 {
width: 100%;
min-height: 100svh;
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The two rendering modes stopped being selector families.
[data-perf=high] .glassand:not([data-perf=high]) .btn.is-ripple .glassbecomegroup-data-[perf=high]/stage:andgroup-data-[perf=css]/stage:group-[.is-ripple]/btn:— verbose, but each element now declares which mode it participates in, and the named groups make the two-level dependency (stage mode AND button replay state) explicit. - The success morph no longer rewrites
innerHTML. Both labels are in the DOM anddata-doneon the button swaps them viagroup-data-done/btn:, so the click handler sets one attribute and one string of status text — no markup parsing at interaction time, and the emoji/glyph can't be lost to a stale template literal. - UI change from the CSS original: the click handler's
setTimeoutis now cleared before rescheduling (rapid clicks previously raced and could strand the button in its success state), the status region clears itself with the label, and the SVG warp is dropped undermotion-reduce:rather than only the stage's background animation.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Add-to-Cart Liquid Glass Ripple Button
Source: https://codefronts.com/motion/tailwind-css-button-hover-effects/tailwind-add-to-cart-liquid-glass-ripple-button/
A Liquid Glass 'Add to Cart' CTA that ripples like a disturbed pool on click — a real SVG displacement map radiates from the pointer and warps the frosted surface, then decays. Includes a success morph, an aria-live status and a High-Performance toggle that swaps the GPU-heavy warp for a pure-CSS ripple.
## HTML
```html
<section class="tbh-36 w-full min-h-svh grid place-items-center p-6 font-tbh36-sans bg-[conic-gradient(from_200deg_at_40%_60%,var(--color-tbh36-g1),var(--color-tbh36-g2),var(--color-tbh36-g3),var(--color-tbh36-g1))] bg-[length:220%_220%] animate-tbh36-bg motion-reduce:animate-none" aria-label="Add to cart ripple glass button demo">
<svg aria-hidden="true" width="0" height="0" class="absolute">
<filter id="tbh36-ripple">
<feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="2" seed="3" result="n">
<animate attributeName="baseFrequency" dur="6s" values="0.02;0.035;0.02" repeatCount="indefinite"></animate>
</feTurbulence>
<feDisplacementMap id="tbh36-disp" in="SourceGraphic" in2="n" scale="0" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
</filter>
</svg>
<div id="tbh-36-stage" data-perf="high" class="group/stage grid justify-items-center gap-4">
<button id="tbh-36-btn" type="button" class="group/btn relative isolate overflow-hidden min-h-[52px] px-[30px] cursor-pointer rounded-full border border-white/45 text-base font-bold text-white bg-[color-mix(in_oklab,white_16%,transparent)] shadow-[inset_0_1px_0_oklch(1_0_0/0.6),0_18px_40px_-18px_oklch(0_0_0/0.7)] backdrop-blur-[16px] backdrop-saturate-[1.8] transition-[box-shadow,background-color,border-color] duration-300 hover:shadow-[inset_0_1px_0_oklch(1_0_0/0.7),0_22px_46px_-18px_oklch(0_0_0/0.7)] focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-[3px] data-done:bg-[color-mix(in_oklab,var(--color-tbh36-accent)_70%,transparent)] data-done:border-transparent">
<span aria-hidden="true" class="absolute inset-0 z-0 bg-[radial-gradient(140px_140px_at_var(--mx,50%)_var(--my,50%),oklch(1_0_0/0.4),transparent_60%)] group-data-[perf=high]/stage:[filter:url(#tbh36-ripple)] group-data-[perf=css]/stage:group-[.is-ripple]/btn:animate-tbh36-css motion-reduce:[filter:none]"></span>
<span class="relative z-[1] inline-flex items-center gap-[9px] transition-[opacity,scale] duration-300 group-data-done/btn:scale-[1.04]">
<span class="inline-flex items-center gap-[9px] group-data-done/btn:hidden">
<span aria-hidden="true" class="text-[17px]">🛒</span>Add to Cart · $89
</span>
<span class="hidden items-center gap-[9px] group-data-done/btn:inline-flex">
<span aria-hidden="true">✓</span>Added to Cart
</span>
</span>
</button>
<p id="tbh-36-status" aria-live="polite" class="min-h-[1.2em] text-[13px] font-semibold text-white [text-shadow:0_1px_6px_oklch(0_0_0/0.3)]"></p>
<label class="flex items-center gap-[7px] text-[12.5px] text-white/85 cursor-pointer">
<input id="tbh-36-perf" type="checkbox" class="accent-tbh36-accent"> High-Performance Mode (CSS ripple)
</label>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tbh36-accent: #4ec98d;
--color-tbh36-accent: oklch(0.68 0.18 155);
--color-tbh36-g1: #0ea5e9;
--color-tbh36-g1: oklch(0.7 0.15 230);
--color-tbh36-g2: #6366f1;
--color-tbh36-g2: oklch(0.58 0.22 275);
--color-tbh36-g3: #ec4899;
--color-tbh36-g3: oklch(0.65 0.24 350);
--font-tbh36-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tbh36-bg: tbh36-bg 20s ease-in-out infinite;
--animate-tbh36-css: tbh36-css 0.6s ease-out;
}
/* Stays CSS: the drifting stage gradient and the fallback ripple keyframes. */
@keyframes tbh36-bg {
50% {
background-position: 100% 100%;
}
}
@keyframes tbh36-css {
0% {
scale: 0.3;
opacity: 0.9;
}
100% {
scale: 2.4;
opacity: 0;
}
}
/* Stays CSS: Safari < 16.4 prefixed backdrop-filter. */
.tbh-36 button {
-webkit-backdrop-filter: blur(16px) saturate(180%);
}
.tbh-36 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
const stage = document.querySelector('#tbh-36-stage');
const btn = document.querySelector('#tbh-36-btn');
const disp = document.querySelector('#tbh36-disp');
const status = document.querySelector('#tbh-36-status');
const perf = document.querySelector('#tbh-36-perf');
if (!stage || !btn) return;
perf.addEventListener('change', () => { stage.dataset.perf = perf.checked ? 'css' : 'high'; });
const setPos = e => {
const r = btn.getBoundingClientRect();
btn.style.setProperty('--mx', (e.clientX - r.left) + 'px');
btn.style.setProperty('--my', (e.clientY - r.top) + 'px');
};
btn.addEventListener('pointermove', setPos);
let raf;
const rippleSVG = () => {
if (!disp) return;
let s = 26;
cancelAnimationFrame(raf);
const step = () => {
s -= 1.6;
if (s <= 0) { disp.setAttribute('scale', 0); return; }
disp.setAttribute('scale', s);
raf = requestAnimationFrame(step);
};
step();
};
let reset;
btn.addEventListener('click', e => {
setPos(e);
if (stage.dataset.perf === 'high') rippleSVG();
else { btn.classList.remove('is-ripple'); void btn.offsetWidth; btn.classList.add('is-ripple'); }
btn.dataset.done = '';
status.textContent = 'Added to cart — 1 item';
clearTimeout(reset);
reset = setTimeout(() => { delete btn.dataset.done; status.textContent = ''; }, 1600);
});
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Add-to-Cart Liquid Glass Ripple Button
Source: https://codefronts.com/motion/tailwind-css-button-hover-effects/tailwind-add-to-cart-liquid-glass-ripple-button/
A Liquid Glass 'Add to Cart' CTA that ripples like a disturbed pool on click — a real SVG displacement map radiates from the pointer and warps the frosted surface, then decays. Includes a success morph, an aria-live status and a High-Performance toggle that swaps the GPU-heavy warp for a pure-CSS ripple.
## HTML
```html
<section class="tbh-36 w-full min-h-svh grid place-items-center p-6 font-tbh36-sans bg-[conic-gradient(from_200deg_at_40%_60%,var(--color-tbh36-g1),var(--color-tbh36-g2),var(--color-tbh36-g3),var(--color-tbh36-g1))] bg-[length:220%_220%] animate-tbh36-bg motion-reduce:animate-none" aria-label="Add to cart ripple glass button demo">
<svg aria-hidden="true" width="0" height="0" class="absolute">
<filter id="tbh36-ripple">
<feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="2" seed="3" result="n">
<animate attributeName="baseFrequency" dur="6s" values="0.02;0.035;0.02" repeatCount="indefinite"></animate>
</feTurbulence>
<feDisplacementMap id="tbh36-disp" in="SourceGraphic" in2="n" scale="0" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
</filter>
</svg>
<div id="tbh-36-stage" data-perf="high" class="group/stage grid justify-items-center gap-4">
<button id="tbh-36-btn" type="button" class="group/btn relative isolate overflow-hidden min-h-[52px] px-[30px] cursor-pointer rounded-full border border-white/45 text-base font-bold text-white bg-[color-mix(in_oklab,white_16%,transparent)] shadow-[inset_0_1px_0_oklch(1_0_0/0.6),0_18px_40px_-18px_oklch(0_0_0/0.7)] backdrop-blur-[16px] backdrop-saturate-[1.8] transition-[box-shadow,background-color,border-color] duration-300 hover:shadow-[inset_0_1px_0_oklch(1_0_0/0.7),0_22px_46px_-18px_oklch(0_0_0/0.7)] focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-[3px] data-done:bg-[color-mix(in_oklab,var(--color-tbh36-accent)_70%,transparent)] data-done:border-transparent">
<span aria-hidden="true" class="absolute inset-0 z-0 bg-[radial-gradient(140px_140px_at_var(--mx,50%)_var(--my,50%),oklch(1_0_0/0.4),transparent_60%)] group-data-[perf=high]/stage:[filter:url(#tbh36-ripple)] group-data-[perf=css]/stage:group-[.is-ripple]/btn:animate-tbh36-css motion-reduce:[filter:none]"></span>
<span class="relative z-[1] inline-flex items-center gap-[9px] transition-[opacity,scale] duration-300 group-data-done/btn:scale-[1.04]">
<span class="inline-flex items-center gap-[9px] group-data-done/btn:hidden">
<span aria-hidden="true" class="text-[17px]">🛒</span>Add to Cart · $89
</span>
<span class="hidden items-center gap-[9px] group-data-done/btn:inline-flex">
<span aria-hidden="true">✓</span>Added to Cart
</span>
</span>
</button>
<p id="tbh-36-status" aria-live="polite" class="min-h-[1.2em] text-[13px] font-semibold text-white [text-shadow:0_1px_6px_oklch(0_0_0/0.3)]"></p>
<label class="flex items-center gap-[7px] text-[12.5px] text-white/85 cursor-pointer">
<input id="tbh-36-perf" type="checkbox" class="accent-tbh36-accent"> High-Performance Mode (CSS ripple)
</label>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tbh36-accent: #4ec98d;
--color-tbh36-accent: oklch(0.68 0.18 155);
--color-tbh36-g1: #0ea5e9;
--color-tbh36-g1: oklch(0.7 0.15 230);
--color-tbh36-g2: #6366f1;
--color-tbh36-g2: oklch(0.58 0.22 275);
--color-tbh36-g3: #ec4899;
--color-tbh36-g3: oklch(0.65 0.24 350);
--font-tbh36-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tbh36-bg: tbh36-bg 20s ease-in-out infinite;
--animate-tbh36-css: tbh36-css 0.6s ease-out;
}
/* Stays CSS: the drifting stage gradient and the fallback ripple keyframes. */
@keyframes tbh36-bg {
50% {
background-position: 100% 100%;
}
}
@keyframes tbh36-css {
0% {
scale: 0.3;
opacity: 0.9;
}
100% {
scale: 2.4;
opacity: 0;
}
}
/* Stays CSS: Safari < 16.4 prefixed backdrop-filter. */
.tbh-36 button {
-webkit-backdrop-filter: blur(16px) saturate(180%);
}
.tbh-36 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
const stage = document.querySelector('#tbh-36-stage');
const btn = document.querySelector('#tbh-36-btn');
const disp = document.querySelector('#tbh36-disp');
const status = document.querySelector('#tbh-36-status');
const perf = document.querySelector('#tbh-36-perf');
if (!stage || !btn) return;
perf.addEventListener('change', () => { stage.dataset.perf = perf.checked ? 'css' : 'high'; });
const setPos = e => {
const r = btn.getBoundingClientRect();
btn.style.setProperty('--mx', (e.clientX - r.left) + 'px');
btn.style.setProperty('--my', (e.clientY - r.top) + 'px');
};
btn.addEventListener('pointermove', setPos);
let raf;
const rippleSVG = () => {
if (!disp) return;
let s = 26;
cancelAnimationFrame(raf);
const step = () => {
s -= 1.6;
if (s <= 0) { disp.setAttribute('scale', 0); return; }
disp.setAttribute('scale', s);
raf = requestAnimationFrame(step);
};
step();
};
let reset;
btn.addEventListener('click', e => {
setPos(e);
if (stage.dataset.perf === 'high') rippleSVG();
else { btn.classList.remove('is-ripple'); void btn.offsetWidth; btn.classList.add('is-ripple'); }
btn.dataset.done = '';
status.textContent = 'Added to cart — 1 item';
clearTimeout(reset);
reset = setTimeout(() => { delete btn.dataset.done; status.textContent = ''; }, 1600);
});
})();
```How this works
Two ripple implementations, one control. The stage carries data-perf="high" or "css", and every mode-dependent utility rides a group-data-[perf=...] variant. That is the port's real gain here: in CSS this needed two descendant selector families ([data-perf=high] .glass, :not([data-perf=high]) .btn.is-ripple .glass), and now each element states which mode it belongs to.
The warp itself must stay SVG. feTurbulence + feDisplacementMap have no CSS equivalent, and the animated scale is driven imperatively: a requestAnimationFrame loop ramps it from 26 to 0 and then stops. Utilities cannot express a per-frame filter primitive attribute, and should not try.
The success state is markup, not innerHTML. The original rewrote the label's innerHTML on click; this version toggles data-done on the button and swaps two pre-rendered labels with group-data-done/btn:hidden. The checkmark label exists in the DOM from the start, so nothing is parsed at interaction time and the aria-live region remains the single source of announcement.
Make it yours
- Ripple intensity: the starting
scale(26) and decay step (1.6) in the controller — the only two numbers the script owns. - Success colour:
--color-tbh36-accent, read by the done state's fill. - Default mode: flip the stage's initial
data-perftocssto ship the cheap ripple by default. - Glass tint:
bg-[color-mix(in_oklab,white_16%,transparent)]— raise the percentage over busy backgrounds.
Gotchas — read before shipping
- Animating
feDisplacementMap'sscaleevery frame is the most GPU-expensive effect in this collection. Decay it fast (under 400ms) and cancel the loop at zero — never leave it running idle. - Firefox and low-power devices need the CSS fallback; the toggle exists so a user can choose it, and
motion-reduce:drops the filter tononeunconditionally. - Keep the label readable during the warp: the displacement applies to the glass layer only, never to the
relativelabel above it. - The status text is an
aria-live="polite"region with a reservedmin-h-[1.2em], so announcing 'Added to cart' neither reflows the stage nor interrupts the user mid-sentence. filter: url(#id)is a document-global reference, hence thetbh36-prefix on the filter and its primitive — two copies of an un-namespaced id silently resolve to the first.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ (CSS ripple) | 111+ |
The SVG warp is progressive enhancement — backdrop-filter needs Chrome 76 / Safari 14 prefixed / Firefox 103, and where the filter is unavailable or the High-Performance toggle is on, the button falls back to a pure-CSS radial ripple. With JS disabled it is a static frosted capsule with working hover and focus.