Skip to content

Ripple

Material-style ripple effect triggered on click — pure CSS, no JavaScript. Uses @property transitions to expand a radial gradient outward from the click point. The ripple always completes its full duration, even if the click is released early.

Import

Included in @import 'tw-jib-css'. To import individually:

css
@import 'tw-jib-css/ripple';

Quick Reference

Class Styles
bg-rippleEnables ripple effect on :active via radial-gradient animation
ripple-color-<color>--ripple-color: <color>
ripple-color-<color>/<opacity>--ripple-color: color-mix(in oklch, <color> calc(<opacity> * 1%), transparent)
ripple-color-current--ripple-color: currentColor
ripple-color-[<value>]--ripple-color: <value>
ripple-duration-<number>--ripple-duration: calc(<number> * 10ms)
ripple-duration-[<value>]--ripple-duration: <value>
ripple-position-center--ripple-position: center
ripple-position-top--ripple-position: top
ripple-position-bottom--ripple-position: bottom
ripple-position-left--ripple-position: left
ripple-position-right--ripple-position: right

Basic Usage

Add bg-ripple to any element to enable the ripple effect on click:

Ripple Colour

The default ripple is white at 20% opacity. Use ripple-color-{color} to customise:

white
indigo-300
amber-300
current

Use ripple-color-current to match the ripple to the element's text colour. This is useful for outlined or ghost buttons where the text colour defines the theme.

Opacity

Opacity is applied using color-mix(in oklch). See the Colour Spaces guide to learn why oklch is used as the default mixing space.

Control ripple opacity with the slash modifier:

/90
/70
/50
/30
/10

Position

Set where the ripple originates. The default is center.

center
top
bottom
left
right

Use arbitrary values to set a precise origin point with ripple-position-[<x>_<y>]:

12px 8px
73% 15%
4px 85%

Duration

Control how long the ripple animation takes. The value is multiplied by 10ms, so ripple-duration-30 = 300ms. The default is 0.3s.

200ms
400ms
800ms
2s

Fade

Enable the ripple to fade out as it expands. By default there is no fade.

fade
fade-50
fade-80
fade-none

Using a custom value

Use the ripple-color-[<value>] syntax to set ripple properties based on a completely custom value:

Using a custom variable

For CSS variables, use the typed bare-value syntax ripple-color-(color:--var). The color type hint tells Tailwind to interpret the variable as a colour:

The same pattern works for all ripple properties. For position, use the position type hint:

html
<div class="bg-ripple ripple-position-(position:--ripple-pos)" style="--ripple-pos: 25% 75%">

Cursor-tracking ripple

By default, ripple-position is a fixed value — the ripple always starts from the same point. To make the ripple originate from where the user actually clicks, bind ripple-position to a CSS variable and update it with JavaScript on each mousedown:

The JavaScript is minimal — convert the cursor position to a percentage and write it to --ripple-pos on each mousedown:

js
// HTML: <button class="ripple-btn bg-ripple ripple-position-(position:--ripple-pos)">Click me</button>

const button = document.querySelector('.ripple-btn');

button.addEventListener('mousedown', (e) => {
  const rect = button.getBoundingClientRect();
  const x = ((e.clientX - rect.left) / rect.width * 100).toFixed(1);
  const y = ((e.clientY - rect.top) / rect.height * 100).toFixed(1);
  button.style.setProperty('--ripple-pos', `${x}% ${y}%`);
});
ts
// HTML: <button class="ripple-btn bg-ripple ripple-position-(position:--ripple-pos)">Click me</button>

const button = document.querySelector<HTMLButtonElement>('.ripple-btn')!;

button.addEventListener('mousedown', (e: MouseEvent) => {
  const rect = button.getBoundingClientRect();
  const x = ((e.clientX - rect.left) / rect.width * 100).toFixed(1);
  const y = ((e.clientY - rect.top) / rect.height * 100).toFixed(1);
  button.style.setProperty('--ripple-pos', `${x}% ${y}%`);
});
jsx
function RippleButton() {
  function handleMouseDown(e) {
    const rect = e.currentTarget.getBoundingClientRect();
    const x = ((e.clientX - rect.left) / rect.width * 100).toFixed(1);
    const y = ((e.clientY - rect.top) / rect.height * 100).toFixed(1);
    e.currentTarget.style.setProperty('--ripple-pos', `${x}% ${y}%`);
  }

  return (
    <button
      className="bg-ripple ripple-position-(position:--ripple-pos)"
      onMouseDown={handleMouseDown}
    >
      Click me
    </button>
  );
}
vue
<template>
  <button
    ref="buttonRef"
    class="bg-ripple ripple-position-(position:--ripple-pos)"
    @mousedown="handleMouseDown"
  >
    Click me
  </button>
</template>

<script setup>
import { useTemplateRef } from 'vue';

const buttonRef = useTemplateRef('buttonRef');

function handleMouseDown(e) {
  const rect = buttonRef.value.getBoundingClientRect();
  const x = ((e.clientX - rect.left) / rect.width * 100).toFixed(1);
  const y = ((e.clientY - rect.top) / rect.height * 100).toFixed(1);
  buttonRef.value.style.setProperty('--ripple-pos', `${x}% ${y}%`);
}
</script>

Why not just set --tw-jib--ripple-position directly?

You could — but using a custom variable via ripple-position-(position:--ripple-pos) keeps the contract explicit. Tailwind sees the utility in your markup and includes the ripple-position rule in the output. Setting the internal variable directly works at runtime, but the utility won't appear in your compiled CSS unless something else references it.

Applying conditionally