Skip to content
Menu

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

Specimens nearby

All Buttons →
FX-004 Buttons