Learn how to create a ripple effect with radial gradients, keyframes, and SVG filters, then see it as a fullscreen scroll transition.
Hover over the preview to trigger the ripple. Adjust speed, number of rings, and color below, then copy the generated CSS.
Ripple Hover Effect
Expanding ring animation on hover with configurable style, speed, blur, and scale
A ripple effect is an animation that mimics the concentric circles created when a drop hits a water surface. In web design, it's used as visual feedback on buttons and interactive elements (popularised by Material Design), as hover and click animations on cards and links, as background or hero-section decoration on water-themed and wellness sites, and as fullscreen scroll transitions between sections.
The CSS version above works for hover and click interactions. But imagine this ripple effect playing between fullscreen sections as users scroll through your website, with sine-wave displacement computed per-pixel on the GPU, creating realistic fluid distortion at 60 fps.
Scroll inside the preview below and adjust the speed to see how it feels at different paces:
Included out of the box
The water ripple applies an SVG <filter> to an image element using the CSS filter: url(#id) property. Inside the filter, <feTurbulence> generates Perlin noise, a mathematically smooth random pattern, and <feDisplacementMap> uses that noise to shift pixels in the source image. The result is an organic, fluid-like distortion that looks like light refracting through a water surface.
<svg style="position:absolute; width:0; height:0">
<filter id="water-ripple">
<feTurbulence baseFrequency="0.01 0.05" numOctaves="2" result="turb">
<animate attributeName="baseFrequency" dur="2s"
values="0.01 0.05;0.03 0.08;0.01 0.05"
repeatCount="indefinite"/>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="turb"
scale="15" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</svg>
<!-- Apply with CSS -->
<img style="filter: url(#water-ripple)" src="photo.jpg">
The animation is handled by the <animate> element that oscillates baseFrequency over time. As the frequency changes, the noise pattern shifts, creating continuous undulating movement. The scale attribute on feDisplacementMap controls how aggressively pixels are displaced; low values (5–15) produce a subtle heat-haze; high values (30+) give a heavy underwater distortion.
This is a pure CSS background ripple effect: no JavaScript, no canvas, and it works on any HTML element. SVG filters are supported in 97%+ of browsers, though the filter animation is not GPU-composited in all of them, so performance can vary on complex pages. For fullscreen or scroll-driven ripples, WebGL is the better choice.
The WebGL ripple runs a fragment shader on the GPU that computes per-pixel displacement using a sine-wave function. The shader takes the current UV coordinate, calculates the distance from a ripple origin point, and offsets the texture lookup based on that distance:
// Fragment shader — core displacement
float dist = distance(uv, rippleOrigin);
vec2 offset = normalize(uv - rippleOrigin)
* sin(dist * frequency - time * speed)
* strength;
gl_FragColor = texture2D(image, uv + offset);
Three uniforms control the effect: strength (how far pixels are displaced), frequency (how many wave peaks fit across the image), and speed (how fast the waves propagate). The animation loop uses requestAnimationFrame and updates a single time uniform each frame, so the GPU handles all the heavy computation with no DOM manipulation and no reflow.
Unlike the SVG filter approach, WebGL ripples are fully GPU-composited and scale to any resolution without performance degradation. Images must be served from the same origin or a CORS-enabled CDN, since the shader reads pixel data via texImage2D. This is the approach used by fullPage.js for its fullscreen scroll ripple transitions.
The pure CSS approach to a ripple effect on click uses the background shorthand with a radial-gradient and background-size toggling. In the resting state, the button has a solid background-color. On :hover, an oversized radial gradient creates a tiny transparent dot at the centre surrounded by the hover colour:
.ripple-btn {
background-position: center;
transition: background 0.8s;
}
.ripple-btn:hover {
background: #47a7f5
radial-gradient(circle, transparent 1%, #47a7f5 1%)
center / 15000%;
}
.ripple-btn:active {
background-size: 100%;
transition: background 0s;
}
On :active, background-size snaps to 100% with transition: background 0s, causing the transparent dot to instantly fill the button. When the user releases and the :active state ends, the background-size transitions back to 15000% with an 0.8s ease. This expanding transition is the ripple. The result closely matches the Material UI ripple effect CSS pattern without any JavaScript, making it ideal for lightweight sites.
The limitation is that the ripple always originates from the centre of the button, not from the exact click position. For pointer-accurate ripples (like the real Material Design component), you need JavaScript to set the origin. See the JS Button tab.
The JavaScript approach creates a ripple element dynamically at the exact click position. On each click event, a <span> with border-radius: 50% is created and positioned at the pointer coordinates:
button.addEventListener('click', function(e) {
const circle = document.createElement('span');
const d = Math.max(this.clientWidth, this.clientHeight);
circle.style.width = circle.style.height = d + 'px';
circle.style.left = e.clientX - this.offsetLeft - d/2 + 'px';
circle.style.top = e.clientY - this.offsetTop - d/2 + 'px';
circle.classList.add('ripple');
this.appendChild(circle);
});
A CSS @keyframes animation then scales the circle from scale(0) to scale(2.5) while fading opacity to 0. The button needs position: relative; overflow: hidden so the expanding circle is clipped to its bounds. Any previous ripple element is removed before creating a new one to avoid DOM buildup.
This is the standard pattern used by Material UI, Angular Material, and most component libraries. It gives pixel-accurate ripple origin and works on any clickable element. The CSS-only version above is lighter but always ripples from the centre.
The React implementation wraps the click-to-ripple pattern in a reusable component. A useCallback handler computes the ripple position from e.clientX and e.clientY relative to getBoundingClientRect(), creates a <span> element with the calculated size and offset, and appends it to the button ref. The ripple span uses the same @keyframes scale animation as the vanilla JS version.
This approach uses direct DOM manipulation inside the callback rather than React state, intentionally. Ripple animations are fire-and-forget visual feedback; managing them through useState and re-renders would add unnecessary overhead. The <span> is removed on animationend to keep the DOM clean.
To use it, import the component and wrap any content: <RippleButton>Click me</RippleButton>. The CSS is kept in a separate file (RippleButton.css) so it can be scoped or replaced with CSS modules in your project.
The Vue version uses reactive state to manage ripple instances. Each click pushes a new object ({ x, y, show: true }) to a ripples array, where x and y are calculated from the click event relative to the button's bounding rect. A v-for loop renders a <span> for each active ripple, positioned with inline top and left styles.
The ripple animation uses two stacked @keyframes: one for transform: scale(50) expansion and one for opacity fade. When animationend fires, show is set to false and Vue's <transition-group> handles removal. The mix-blend-mode: screen on the ripple span creates a bright flash effect against dark button backgrounds.
Scoped styles ensure the ripple CSS doesn't leak to other components. The component accepts a text prop for the button label and can be dropped into any Vue 2 or Vue 3 project with the Options API.
The ripple hover effect uses ::before and ::after pseudo-elements positioned on top of a circular button. Each pseudo-element is a ring, a circle with a border and no fill, that expands on hover:
.ripple-btn::before,
.ripple-btn::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid rgba(190, 88, 105, .7);
transform: scale(1);
filter: blur(0);
}
.ripple-btn:hover::before,
.ripple-btn:hover::after {
transform: scale(4);
filter: blur(2px);
border-color: transparent;
transition: transform 1s ease, filter 2s ease;
}
.ripple-btn:hover::after {
transition-delay: 100ms; /* stagger for cascading ripple */
}
Three animation styles are available: Pulse fires a one-shot expansion that fades the border to transparent as it grows; Glow replaces the border with a radial-gradient that fades in and expands, creating a soft halo; Sonar loops the expansion continuously using animation: infinite, producing a radar-ping effect.
Stagger is achieved with transition-delay (Pulse and Glow) or animation-delay (Sonar) on the second and third rings. This creates cascading ripples that feel more organic. All timing, scale, blur, ring count, and colour are controlled via CSS custom properties, making the effect easy to drop into any project.
CSS and WebGL target different use-cases. Choosing the right approach depends on the scale and realism you need.
Best for UI feedback: button clicks, card hovers, and link interactions. Uses radial-gradient + @keyframes, works in every browser, zero dependencies, and very lightweight.
Best for organic image distortion. feTurbulence + feDisplacementMap create a water-like warp that can be animated with <animate>. Good for hero sections and background effects without JavaScript.
Best for fullscreen, high-fidelity fluid simulations. Sine-wave displacement shaders compute per-pixel offset on the GPU, enabling realistic water-surface distortion at 60 fps across entire viewport transitions.
In general, start with CSS for small, contained elements. Move to SVG filters when you need organic distortion on static images. Reach for WebGL when you need real-time, full-viewport ripple transitions driven by scroll or user interaction.
When most developers search for "ripple effect", they're really looking for Material Design's pressed-state feedback, the expanding circle that appears when you tap a button, list item, or card. Google's Material Design system popularised this pattern, and frameworks like Angular Material, MUI, and Vuetify ship it out of the box. Understanding the relationship between a generic CSS ripple button and a Material ripple helps you pick the right approach.
A Material ripple is a touch / click feedback pattern, not a decorative animation. It originates at the exact pointer position, expands to fill the container, and fades out on release. Material 3 adds a subtle state-layer colour shift on top of the ripple to reinforce the interaction.
By contrast, a generic CSS ripple button animation can originate from the centre, loop continuously, and serve a purely decorative role, like water rings or pulsing radar circles. The underlying CSS technique (pseudo-element + radial-gradient + scale keyframes) is the same, but the intent and behaviour differ.
You can approximate Material's pressed-state ripple without JavaScript using ::after and the :active pseudo-class:
.md-ripple {
position: relative;
overflow: hidden;
}
.md-ripple::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
circle at var(--x, 50%) var(--y, 50%),
rgba(255,255,255,.35) 0%,
transparent 60%
);
transform: scale(0);
opacity: 1;
transition: transform .4s ease, opacity .3s ease;
}
.md-ripple:active::after {
transform: scale(2.5);
opacity: 0;
transition: transform .5s ease-out, opacity .4s ease-out;
}
This gives you centred feedback with no dependencies. For a ripple that originates at the exact click position (like the real Material component), you need a small amount of JavaScript to set --x and --y custom properties on the element based on the pointer coordinates.
Use when you're already inside a Material Design system. The ripple is pre-built, handles pointer position, touch events, keyboard focus, and accessibility states for you.
Use when you need a lightweight ripple on a non-Material project, such as a WordPress theme, a landing page, or any site where pulling in a full component library would be overkill. The demo above generates the exact CSS you need.
Ripple effects are core to Material Design button feedback. The expanding circle on click gives users immediate visual confirmation of their interaction.
Aquariums, swimming pools, diving centres, and water sports brands use ripple animations to reinforce their aquatic identity throughout the page.
Calming ripple animations suit wellness and mindfulness applications. The gentle expanding rings evoke stillness and focus.
Subtle ripples add life to static hero sections. A gentle, continuous ripple animation can make an otherwise flat background feel dynamic and immersive.
::before and ::after pseudo-elements with a border and border-radius: 50%, then expand them on :hover with transform: scale() and filter: blur(). Add transition-delay to stagger multiple rings for a cascading ripple. For a continuous radar-ping effect, use @keyframes with animation: infinite instead of transitions. See the Ripple Hover Effect tab above to customise and generate the code.
feTurbulence combined with feDisplacementMap and an <animate> element can produce organic water-like ripple distortion on images. However, CSS-only water ripples are limited to filter-based approximations. For truly realistic fluid simulation with wave propagation, WebGL with sine-wave displacement shaders is required.
radial-gradient + scale) or SVG filter distortion (feTurbulence). They work well for UI feedback like Material Design button clicks. WebGL image ripple effects can simulate real fluid dynamics with sine-wave displacement computed per-pixel on the GPU, producing realistic water surface distortion that responds to scroll position and user interaction.
Add scroll transitions, parallax and animations to your site with fullPage.js. One component, 80+ effects.
Powering fullscreen design for Google, Sony, BBC, eBay & more