← Back to gallery
CSS

Button Ripple Radial Gradient Fill

A press-feedback ripple driven by an animated radial-gradient layer inside an overflow-hidden button, with pointer-tracked origin and a pure CSS centered fallback.

ripplebutton-clickradial-gradientpointer-originoverflow-hiddencss-vars-from-pointerprefers-reduced-motion

Interaction / radial-gradient / button press

Button ripple radial-gradient fill

Reimplements press-feedback ripples using an animated radial-gradient layer clipped to the button bounds, with pointer-tracked origin, a centered CSS-only fallback, and an ambient hover burst for hero CTAs.

Bloom from click origin

Pointer-Tracked

JS reads pointerdown coordinates and drives two CSS custom properties so the gradient center blooms from exactly where the user pressed.

  • pointer coords
  • custom properties
  • wide button

Pure CSS pressed state

Centered Pulse

A smaller action button fills from its geometric center on :active, avoiding any JS and keeping the effect deterministic across devices.

  • :active
  • centered
  • no JS

Emphasis button hover bloom

Ambient Burst

On hover the background auto-rotates a broader radial burst to call attention to a primary call-to-action while click keeps the tighter press ripple.

  • hover
  • CTA
  • wide stops

Ripple inspector

Pointer-Tracked

click button
  • pointer coords
  • custom properties
  • wide button

JS reads pointerdown coordinates and drives two CSS custom properties so the gradient center blooms from exactly where the user pressed. Works well when buttons are wide enough for users to notice the origin — the ripple feels directly caused by the press rather than generic feedback.

Helped you ship something? 🐟 Send my cat a churu

.ripple-button {
  position: relative;
  overflow: hidden;
  background: #1e3a5f;
  color: white;
  border: 0;
  border-radius: 999px;
  padding: 12px 22px;
  isolation: isolate;
}

.ripple-button::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(circle 160px at var(--ripple-x, 50%) var(--ripple-y, 50%), rgba(125, 211, 252, 0.55) 0%, transparent 70%);
  opacity: 0;
  transform: scale(0.2);
  transition: opacity 0.65s ease-out,
              transform 0.65s ease-out;
  pointer-events: none;
  z-index: -1;
}

.ripple-button.is-rippling::after {
  opacity: 1;
  transform: scale(1);
}

@media (prefers-reduced-motion: reduce) {
  .ripple-button::after { transition-duration: 0s; }
}

How to make this

A CSS button ripple uses a clipped radial-gradient pseudo-element, expands it with transform on press, and keeps the real button semantics intact.

html
1<button class="ripple-fill-button" type="button">  Save changes</button> <style>.ripple-fill-button {7  position: relative;  overflow: hidden;  isolation: isolate;  border: 0;  border-radius: 999px;  padding: .8rem 1.25rem;  color: #fff;  background: #1e3a5f;  font: 700 .95rem/1 ui-sans-serif, system-ui;  cursor: pointer;}18.ripple-fill-button::after {  content: "";  position: absolute;  left: 50%;  top: 50%;  width: 12rem;  aspect-ratio: 1;  border-radius: 50%;26  background: radial-gradient(circle,    rgba(125, 211, 252, .58) 0%,    rgba(125, 211, 252, .36) 42%,    transparent 70%);  transform: translate(-50%, -50%) scale(.12);  opacity: 0;  z-index: -1;  pointer-events: none;}35.ripple-fill-button:active::after,.ripple-fill-button:focus-visible::after {  animation: button-ripple-recipe-expand .62s ease-out;}.ripple-fill-button:focus-visible {  outline: 2px solid #7dd3fc;  outline-offset: 4px;}@keyframes button-ripple-recipe-expand {  0% { opacity: .9; transform: translate(-50%, -50%) scale(.08); }  80% { opacity: .28; transform: translate(-50%, -50%) scale(1); }46  100% { opacity: 0; transform: translate(-50%, -50%) scale(1.12); }}@media (prefers-reduced-motion: reduce) {  .ripple-fill-button:active::after,  .ripple-fill-button:focus-visible::after { animation: none; opacity: .35; transform: translate(-50%, -50%) scale(1); }}</style>

Annotated snippet

  1. Line 1Keep a real button. Ripple feedback should enhance the press state, not replace keyboard activation, disabled behavior, form semantics, or focus handling.
    PitfallHow do I keep button ripples accessible?

    Use a real button element, keep focus-visible styling, and make sure keyboard activation gets feedback too. The ripple layer should be decorative and should not contain the accessible label.

  2. Line 7overflow: hidden clips the expanding radial layer to the button radius. Without clipping, the ripple leaks into the surrounding layout.

    Same expanding radial gradient pseudo-element on the middle button. Without overflow: hidden the ripple bleeds onto the Cancel and Delete neighbours every cycle; with it the wave stays inside the Save button.

    PitfallWhy does my ripple bleed outside the button?

    The button needs overflow: hidden and a border radius on the clipping element. If the pseudo-element is outside that clipped box, the radial wave will escape into nearby UI.

  3. Line 18The pseudo-element is the ripple layer. Using a generated layer keeps the button label stable and avoids inserting extra decorative markup.
  4. Line 26The radial gradient fades from accent to transparent, which reads as a fill wave. A flat overlay looks like a flash, not a ripple.

    A shadow spread darkens the whole button edge; a radial layer creates a visible wave from the press area.

    PitfallAre radial-gradient ripples performant?

    One transformed pseudo-element is usually fine. Avoid running many large ripples continuously, animating layout properties, or combining the ripple with heavy blur filters on dense button grids.

  5. Line 35Trigger the same feedback for active pointer presses and focus-visible keyboard activation. Keyboard users should get equivalent confirmation.
    PitfallHow do I keep button ripples accessible?

    Use a real button element, keep focus-visible styling, and make sure keyboard activation gets feedback too. The ripple layer should be decorative and should not contain the accessible label.

  6. Line 46Reduced motion removes the expanding wave and keeps a static accent fill. The button remains visibly focused without an animated burst.
    PitfallWhat should prefers-reduced-motion do for ripple buttons?

    Disable the expanding animation and keep a static pressed or focused cue. Ripple is press feedback, but it should not be the only indicator that the button received input.

Other pitfalls

Can a pure CSS ripple track the pointer position?
Not precisely. Pure CSS can center the ripple or use fixed origins. Pointer-tracked ripples need JavaScript to set custom properties such as --ripple-x and --ripple-y from the pointerdown coordinates.

Notes

Overview

A button ripple draws a soft circle that expands from the click point outward, fading as it grows. The pattern uses a radial gradient on a pseudo-element with origin coordinates passed in as CSS custom properties from a pointer event, so the ripple actually starts at the cursor — not the button center.

When to use it

Reach for ripples on Material-leaning button systems and anywhere a click needs tactile confirmation (submit buttons, toolbar actions, list-item taps). Skip it for inline-text links, tiny icon-only buttons, or any control where the ripple radius would extend past the click target itself. Skip it for buttons that fire rapidly (volume +/-, counter steppers) — rapid clicks pile ripples on top of each other and the effect becomes noise.

How it works

On pointerdown read the click coordinates, subtract the button’s getBoundingClientRect() origin, and write the results into --ripple-x and --ripple-y CSS custom properties on the button itself. A ::after pseudo-element listens for those variables and renders a radial-gradient(circle at var(--ripple-x) var(--ripple-y), ...) that animates from zero radius to past the longest button diagonal. The pseudo-element sits inside an overflow: hidden button so the ripple clips to the button’s rounded corners. A second class .is-rippling toggles on the same pointerdown to retrigger the keyframe; remove it on animationend so the next click starts clean.

Production gotchas

Rapid double-clicks stack two ripples on top of each other; either debounce the trigger to one ripple per animation-duration window or render a queue of pseudo-element clones if you want every click acknowledged. Forgetting overflow: hidden on the button leaks the ripple past the rounded corner and the effect looks amateur. The pseudo-element needs pointer-events: none or it intercepts clicks meant for the button label. Buttons rendered inside a flex/grid container with min-width: 0 can produce zero-width getBoundingClientRect() measurements on the first paint — defer the listener wire-up to requestAnimationFrame after mount.

Accessibility

The ripple is purely decorative — the click event already fires the action; screen readers ignore the pseudo-element. Under prefers-reduced-motion: reduce skip the radius animation entirely and use a flat 60ms opacity fade instead, so the click still has tactile feedback without the outward expansion. Verify the ripple color has at least 3:1 contrast against the button surface or it disappears for low-vision users.

References

Implementation depth

The ripple is press feedback, not the button action itself. Track pointer coordinates into CSS variables for pointer users, but keep the same button state and label for keyboard and screen-reader users.

Use overflow hidden and a bounded radial-gradient so the effect does not bleed outside rounded corners. Reduced motion can switch the ripple to an instant background flash while preserving click acknowledgement.