Experiment #003
Ripple Button
On click, Alpine.js reads the pointer coordinates relative to the button and appends an absolutely positioned span at that point. A single CSS @keyframes animation scales the span from 0 to 2.5 while fading it to transparent, and an animationend listener removes it from the DOM. Keyboard activation centers the ripple, and the whole effect is skipped under prefers-reduced-motion.
Scene
Technical details
- Experiment
- FX-003
- Category
- Buttons
- Stack
- Tailwind + Alpine.js + CSS
- Difficulty
- Intermediate
- Dependencies
- none
- Weight
- HTML 1.8 KB · ALPINE 1.3 KB · CSS 0.8 KB
- Added
- 2026-08-22
- Updated
- 2026-08-22
Lab notes
The wave uses currentColor at 0.25 opacity, so it adapts to any button palette for free. Tune the 600ms duration and the 2.5 end scale together: a larger scale needs a longer duration to keep the edge speed calm. The button must keep position: relative and overflow: hidden, and rapid clicks are fine — each wave is its own element.
Take the code
HTML — code.html
<!-- Ripple Button — Tailwind + Alpine.js -->
<button
type="button"
x-data="{
ripple(event) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return
}
const rect = this.$el.getBoundingClientRect()
const size = Math.max(rect.width, rect.height)
const fromKeyboard = event.detail === 0
const x = fromKeyboard ? rect.width / 2 : event.clientX - rect.left
const y = fromKeyboard ? rect.height / 2 : event.clientY - rect.top
const wave = document.createElement('span')
wave.className = 'fx-ripple-button-wave'
wave.style.width = `${size}px`
wave.style.height = `${size}px`
wave.style.left = `${x - size / 2}px`
wave.style.top = `${y - size / 2}px`
wave.addEventListener('animationend', () => wave.remove())
this.$el.appendChild(wave)
},
}"
@click="ripple"
class="relative overflow-hidden rounded-full bg-zinc-900 px-7 py-3
text-sm font-medium text-white hover:bg-zinc-700
focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-zinc-500"
>
Click me
</button>
<style>
.fx-ripple-button-wave {
position: absolute;
border-radius: 50%;
background-color: currentColor;
opacity: 0.25;
transform: scale(0);
pointer-events: none;
animation: fx-ripple-button-expand 600ms ease-out forwards;
will-change: transform, opacity;
}
@keyframes fx-ripple-button-expand {
to {
transform: scale(2.5);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.fx-ripple-button-wave {
animation-duration: 1ms;
opacity: 0;
}
}
</style>
Alpine — alpine.js
/*
* Ripple Button — reusable Alpine.js component.
*
* Register once (before Alpine.start), then use on any button:
*
* <button x-data="rippleButton" @click="ripple" class="relative overflow-hidden ...">
*
* The host element must have position: relative and overflow: hidden.
* Requires the .fx-ripple-button-wave styles (see styles.css).
*/
document.addEventListener('alpine:init', () => {
Alpine.data('rippleButton', () => ({
ripple(event) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return
}
const rect = this.$el.getBoundingClientRect()
const size = Math.max(rect.width, rect.height)
const fromKeyboard = event.detail === 0
const x = fromKeyboard ? rect.width / 2 : event.clientX - rect.left
const y = fromKeyboard ? rect.height / 2 : event.clientY - rect.top
const wave = document.createElement('span')
wave.className = 'fx-ripple-button-wave'
wave.style.width = `${size}px`
wave.style.height = `${size}px`
wave.style.left = `${x - size / 2}px`
wave.style.top = `${y - size / 2}px`
wave.addEventListener('animationend', () => wave.remove())
this.$el.appendChild(wave)
},
}))
})
CSS — styles.css
/*
* Ripple Button — vanilla CSS.
*
* .fx-ripple-button goes on the host button (or replace it with your own
* position: relative + overflow: hidden utilities). .fx-ripple-button-wave
* is created per click by the JS and removed on animationend.
*/
.fx-ripple-button {
position: relative;
overflow: hidden;
}
.fx-ripple-button-wave {
position: absolute;
border-radius: 50%;
background-color: currentColor;
opacity: 0.25;
transform: scale(0);
pointer-events: none;
animation: fx-ripple-button-expand 600ms ease-out forwards;
will-change: transform, opacity;
}
@keyframes fx-ripple-button-expand {
to {
transform: scale(2.5);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.fx-ripple-button-wave {
animation-duration: 1ms;
opacity: 0;
}
}
Adjacent