CSS Ripple Effect: 16 Examples and the Code for Each

A CSS ripple effect is a fading circle that spreads from the spot of a click or tap, a pattern Material Design made common.

CSS cannot read pointer coordinates, so a stylesheet-only ripple always starts at the center. Below are 16 CSS ripple effect examples you can try and copy, followed by a guide to making the wave follow the click.

By Bogdan Sandu · Updated October 28, 2026

Click, tap and keyboard ripples

The core techniques. A tiny script that starts the wave at the pointer, the CSS-only version that always starts at the center, keyboard activation, a pseudo-element ripple with no extra DOM, and a ripple drawn with an animated @property radius.

Material-style ripple from the pointer

  • pointerdown
  • --rp-x / --rp-y
  • animationend cleanup

Order summary

Linen tote, sand
$36.00
Shipping
$12.00
Total
$48.00

Click or tap anywhere on the buttons.

On pointerdown the script measures the button, creates a span with createElement and passes the press point to CSS as --rp-x and --rp-y. The span is twice the box diagonal, so a press in one corner still reaches the opposite one, and it removes itself on animationend.

View code
.rp-pay {
  display: grid;
  place-items: center;
  width: 100%;
  padding: 8px 0;
}
.rp-pay__card {
  display: grid;
  gap: 10px;
  width: min(320px, 100%);
  padding: 22px;
  border-radius: 18px;
  background: #fff;
  color: #1b2130;
  box-shadow: 0 12px 32px rgba(20, 26, 40, .1);
}
.rp-pay__title {
  margin: 0;
  font-family: inherit;
  font-size: 1.05rem;
  font-weight: 600;
  letter-spacing: 0;
  color: #1b2130;
}
.rp-pay__lines {
  display: grid;
  gap: 6px;
  margin: 0 0 6px;
  font-size: .9rem;
}
.rp-pay__lines div {
  display: flex;
  justify-content: space-between;
  gap: 12px;
}
.rp-pay__lines dt {
  color: #545c6e;
}
.rp-pay__lines dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}
.rp-pay__total {
  padding-top: 8px;
  border-top: 1px solid #e3e6ee;
  font-weight: 700;
}
.rp-pay__total dt {
  color: #1b2130;
}
.rp-pay__btn {
  position: relative;
  z-index: 0;
  overflow: hidden;
  min-height: 48px;
  padding: 0 18px;
  border-radius: 12px;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-pay__btn--main {
  border: 0;
  background: #2f3bd1;
  color: #fff;
}
.rp-pay__btn--ghost {
  border: 1px solid #c9cfdc;
  background: #fff;
  color: #1b2130;
}
.rp-pay__btn:hover {
  filter: brightness(.96);
}
.rp-pay__btn:focus-visible {
  outline: 3px solid #2f3bd1;
  outline-offset: 3px;
}
.rp-pay__label {
  position: relative;
  z-index: 1;
}
.rp-pay__ink {
  position: absolute;
  left: calc(var(--rp-x) - var(--rp-size) / 2);
  top: calc(var(--rp-y) - var(--rp-size) / 2);
  width: var(--rp-size);
  height: var(--rp-size);
  border-radius: 50%;
  background: currentColor;
  opacity: 0;
  pointer-events: none;
  animation: rp-pay-fade .3s linear;
}
.rp-pay__status {
  min-height: 1.4em;
  margin: 0;
  font-size: .85rem;
  color: #1d6b44;
}
@keyframes rp-pay-wave {
  from { transform: scale(0); opacity: .32; }
  to { transform: scale(1); opacity: 0; }
}
@keyframes rp-pay-fade {
  from { opacity: .2; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-pay__ink {
    animation: rp-pay-wave .6s ease-out;
  }
}

CSS-only center ripple on :active

  • :active
  • transition swap
  • scale

Publish this post?

Readers on the Field Journal list get it in their inbox at 8:00 tomorrow.

Click or tap anywhere on the buttons.

No script at all. While the button is pressed, :active snaps the pseudo-element to scale 0 with a 0s transition, and on release the normal transition grows it back to full size while it fades. CSS cannot read the pointer, so the wave always starts at the center and plays on release.

View code
.rp-publish {
  width: min(340px, 100%);
  padding: 22px;
  border-radius: 16px;
  background: #fff;
  color: #1c2433;
  box-shadow: 0 1px 2px rgba(20, 30, 50, .08), 0 14px 34px rgba(20, 30, 50, .1);
}
.rp-publish__title {
  margin: 0 0 6px;
  font-family: inherit;
  font-size: 1.1rem;
  font-weight: 700;
  letter-spacing: 0;
  color: #1c2433;
}
.rp-publish__text {
  margin: 0 0 18px;
  font-size: .92rem;
  line-height: 1.5;
  color: #4d5668;
}
.rp-publish__row {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 10px;
}
.rp-publish__btn {
  position: relative;
  z-index: 0;
  overflow: hidden;
  min-height: 44px;
  padding: 0 20px;
  border-radius: 999px;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-publish__btn--ghost {
  border: 1px solid #cdd3de;
  background: #fff;
  color: #24324a;
}
.rp-publish__btn--main {
  border: 0;
  background: #0f6b55;
  color: #fff;
}
.rp-publish__btn::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: -1;
  width: 260%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: currentColor;
  opacity: 0;
  translate: -50% -50%;
  pointer-events: none;
  transition: opacity .7s ease-out;
}
.rp-publish__btn:active::after {
  scale: 0;
  opacity: .3;
  transition: 0s;
}
.rp-publish__btn:focus-visible {
  outline: 3px solid #0f6b55;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .rp-publish__btn::after {
    transition: scale .55s ease-out, opacity .7s ease-out;
  }
}
@media (prefers-reduced-motion: reduce) {
  .rp-publish__btn:active::after {
    scale: 1;
    opacity: .18;
  }
}

Ripple on keyboard activation

  • click detail 0
  • Enter / Space
  • :focus-visible

Northwind Studio invited you to join the Design Ops team.

Press Tab to reach a button, then Enter or Space.

Enter and Space never fire pointerdown, so keyboard users usually see no wave. A click with event.detail of 0 comes from the keyboard, and the script starts that ripple at the center. Pointer presses still start at the pointer, and the focus ring does not depend on the ripple.

View code
.rp-invite {
  display: grid;
  gap: 16px;
  width: min(340px, 100%);
  padding: 20px;
  border: 1px solid #eadfcd;
  border-radius: 16px;
  background: #fffaf2;
  color: #2b2118;
}
.rp-invite__head {
  display: flex;
  align-items: center;
  gap: 14px;
}
.rp-invite__logo {
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 12px;
  background: #2b2118;
  color: #fffaf2;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.3rem;
  font-weight: 700;
}
.rp-invite__text {
  margin: 0;
  font-size: .95rem;
  line-height: 1.45;
}
.rp-invite__row {
  display: grid;
  grid-template-columns: 1fr 1.4fr;
  gap: 10px;
}
.rp-invite__btn {
  position: relative;
  z-index: 0;
  overflow: hidden;
  min-height: 46px;
  padding: 0 12px;
  border-radius: 10px;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-invite__btn--ghost {
  border: 1px solid #d6c7ae;
  background: transparent;
  color: #2b2118;
}
.rp-invite__btn--main {
  border: 0;
  background: #b4381e;
  color: #fff;
}
.rp-invite__btn:focus-visible {
  outline: 3px solid #2b2118;
  outline-offset: 3px;
}
.rp-invite__label {
  position: relative;
  z-index: 1;
}
.rp-invite__ink {
  position: absolute;
  left: calc(var(--rp-x) - var(--rp-size) / 2);
  top: calc(var(--rp-y) - var(--rp-size) / 2);
  width: var(--rp-size);
  height: var(--rp-size);
  border-radius: 50%;
  background: currentColor;
  opacity: 0;
  pointer-events: none;
  animation: rp-invite-fade .3s linear;
}
.rp-invite__status {
  min-height: 1.4em;
  margin: -4px 0 0;
  font-size: .85rem;
  color: #6b4a2a;
}
@keyframes rp-invite-wave {
  from { transform: scale(0); opacity: .3; }
  to { transform: scale(1); opacity: 0; }
}
@keyframes rp-invite-fade {
  from { opacity: .2; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-invite__ink {
    animation: rp-invite-wave .6s ease-out;
  }
}

Pseudo-element ripple, transform only

  • ::after
  • transform: scale
  • no extra DOM

Step 2 of 3

Shipping details

Click or tap anywhere on the buttons.

The wave is the button::after pseudo-element, so no node is created. The script only writes the press point and diameter as custom properties and restarts one class. The animation touches transform and opacity, never width or height, which keeps it off layout and paint. The trade-off: a fast second tap restarts the wave instead of layering a new one.

View code
.rp-step {
  display: grid;
  gap: 8px;
  width: min(340px, 100%);
  padding: 22px;
  border-radius: 14px;
  background: #fff;
  color: #111827;
  box-shadow: 0 10px 30px rgba(17, 24, 39, .08);
}
.rp-step__meta {
  margin: 0;
  font-size: .8rem;
  font-weight: 600;
  color: #555e6e;
}
.rp-step__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.35rem;
  font-weight: 600;
  letter-spacing: -.01em;
  color: #111827;
}
.rp-step__bar {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
  margin: 6px 0 14px;
}
.rp-step__bar span {
  height: 4px;
  border-radius: 4px;
  background: #e2e5ea;
}
.rp-step__bar span:nth-child(1),
.rp-step:not([data-step]) .rp-step__bar span:nth-child(2),
.rp-step[data-step="2"] .rp-step__bar span:nth-child(2),
.rp-step[data-step="3"] .rp-step__bar span {
  background: #111827;
}
.rp-step__next {
  position: relative;
  z-index: 0;
  overflow: hidden;
  min-height: 50px;
  border: 0;
  border-radius: 12px;
  background: #111827;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-step__next::after {
  content: "";
  position: absolute;
  left: calc(var(--rp-x, 50%) - var(--rp-d, 0px) / 2);
  top: calc(var(--rp-y, 50%) - var(--rp-d, 0px) / 2);
  width: var(--rp-d, 0px);
  height: var(--rp-d, 0px);
  border-radius: 50%;
  background: #fff;
  opacity: 0;
  pointer-events: none;
}
.rp-step__next.rp-step--wave::after {
  animation: rp-step-fade .3s linear;
}
.rp-step__label {
  position: relative;
  z-index: 1;
}
.rp-step__next:focus-visible {
  outline: 3px solid #111827;
  outline-offset: 3px;
}
@keyframes rp-step-wave {
  from { transform: scale(0); opacity: .3; }
  to { transform: scale(1); opacity: 0; }
}
@keyframes rp-step-fade {
  from { opacity: .22; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-step__next.rp-step--wave::after {
    animation: rp-step-wave .6s ease-out;
  }
}

Ripple via an @property radius

  • @property
  • radial-gradient hard stop
  • aria-pressed

Click or tap anywhere on the buttons.

The circle is a hard-stop radial-gradient whose stop position is a registered <percentage> custom property. With farthest-corner sizing, 100% reaches exactly the farthest corner from the press, so animating --rp-r from 0% floods the tile with its new state color. Unregistered, the property would only flip at the midpoint.

View code
@property --rp-r {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}
.rp-home {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 150px));
  gap: 12px;
}
.rp-home__tile {
  --rp-from: #ffc145;
  --rp-to: #e6e9ef;
  display: grid;
  align-content: end;
  justify-items: start;
  gap: 2px;
  aspect-ratio: 1;
  padding: 16px;
  border: 0;
  border-radius: 22px;
  background: radial-gradient(circle farthest-corner at var(--rp-x, 50%) var(--rp-y, 50%), var(--rp-to) var(--rp-r, 100%), var(--rp-from) var(--rp-r, 100%));
  color: #182030;
  font: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-home__tile[aria-pressed="true"] {
  --rp-from: #e6e9ef;
  --rp-to: #ffc145;
  color: #2a1c00;
}
.rp-home__tile svg {
  margin-bottom: auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.rp-home__name {
  font-weight: 700;
}
.rp-home__state {
  font-size: .85rem;
}
.rp-home__tile:focus-visible {
  outline: 3px solid #182030;
  outline-offset: 3px;
}
@keyframes rp-home-on {
  from { --rp-r: 0%; }
}
@keyframes rp-home-off {
  from { --rp-r: 0%; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-home__tile.rp-home--touched[aria-pressed="true"] {
    animation: rp-home-on .6s ease-out;
  }
  .rp-home__tile.rp-home--touched[aria-pressed="false"] {
    animation: rp-home-off .6s ease-out;
  }
}

Ripples on real controls

Where the wave lives in an interface: list rows, cards, round icon buttons, a floating action button and selection controls. Each one shows a different clipping rule, bounded inside the element or unbounded past its edge.

Ripple on list items and menu rows

  • event delegation
  • closest()
  • aria-current

Inbox

2 unread

Tap a message row.

One pointerdown listener on the list finds the pressed row with closest(), so a long list or a menu does not need a handler per row. Full-width rows are where a click-origin wave matters most, because a centered one would start far from the finger.

View code
.rp-inbox {
  width: min(360px, 100%);
  overflow: hidden;
  border-radius: 16px;
  background: #fff;
  color: #1a2233;
  box-shadow: 0 10px 30px rgba(26, 34, 51, .09);
}
.rp-inbox__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  padding: 16px 18px 10px;
}
.rp-inbox__title {
  margin: 0;
  font-family: inherit;
  font-size: 1.1rem;
  font-weight: 700;
  letter-spacing: 0;
  color: #1a2233;
}
.rp-inbox__count {
  margin: 0;
  font-size: .82rem;
  color: #556076;
}
.rp-inbox__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.rp-inbox__list li {
  margin: 0;
  border-top: 1px solid #edf0f5;
}
.rp-inbox__row {
  position: relative;
  z-index: 0;
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 2px 12px;
  width: 100%;
  overflow: hidden;
  padding: 12px 18px 12px 30px;
  border: 0;
  background: #fff;
  color: #1a2233;
  font: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-inbox__row:hover {
  background: #f6f8fb;
}
.rp-inbox__row[aria-current="true"] {
  background: #e8f0ff;
}
.rp-inbox__row--unread::before {
  content: "";
  position: absolute;
  left: 13px;
  top: 19px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #2563eb;
}
.rp-inbox__from {
  position: relative;
  z-index: 1;
  overflow: hidden;
  font-size: .92rem;
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.rp-inbox__row--unread .rp-inbox__from {
  font-weight: 700;
}
.rp-inbox__time {
  position: relative;
  z-index: 1;
  font-size: .78rem;
  color: #556076;
}
.rp-inbox__subject {
  position: relative;
  z-index: 1;
  grid-column: 1 / -1;
  overflow: hidden;
  font-size: .85rem;
  color: #485269;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.rp-inbox__row:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: -3px;
}
.rp-inbox__ink {
  position: absolute;
  left: calc(var(--rp-x) - var(--rp-size) / 2);
  top: calc(var(--rp-y) - var(--rp-size) / 2);
  width: var(--rp-size);
  height: var(--rp-size);
  border-radius: 50%;
  background: #2563eb;
  opacity: 0;
  pointer-events: none;
  animation: rp-inbox-fade .3s linear;
}
@keyframes rp-inbox-wave {
  from { transform: scale(0); opacity: .18; }
  to { transform: scale(1); opacity: 0; }
}
@keyframes rp-inbox-fade {
  from { opacity: .12; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-inbox__ink {
    animation: rp-inbox-wave .7s ease-out;
  }
}

Ripple on selectable cards

  • button card
  • aria-pressed
  • isolation

Pick a trip.

The whole card is one button, so the wave covers photo and text alike and starts under the finger. The ink sits above the image but below the text, and z-index: 0 on the card makes it a stacking context, the usual fix for Safari corners poking through a rounded clip.

View code
.rp-trip {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 14px;
  width: min(400px, 100%);
}
.rp-trip__card {
  position: relative;
  z-index: 0;
  display: grid;
  overflow: hidden;
  padding: 0;
  border: 2px solid transparent;
  border-radius: 16px;
  background: #fff;
  color: #1d2430;
  font: inherit;
  text-align: left;
  cursor: pointer;
  box-shadow: 0 8px 24px rgba(29, 36, 48, .1);
  -webkit-tap-highlight-color: transparent;
}
.rp-trip__card[aria-pressed="true"] {
  border-color: #0f5f8a;
}
.rp-trip__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.rp-trip__body {
  position: relative;
  z-index: 2;
  display: grid;
  gap: 2px;
  padding: 12px 14px 14px;
}
.rp-trip__name {
  font-weight: 700;
}
.rp-trip__meta {
  font-size: .84rem;
  color: #4b5568;
}
.rp-trip__card[aria-pressed="true"] .rp-trip__name::after {
  content: " \2713";
  color: #0f5f8a;
}
.rp-trip__card:focus-visible {
  outline: 3px solid #0f5f8a;
  outline-offset: 3px;
}
.rp-trip__ink {
  position: absolute;
  z-index: 1;
  left: calc(var(--rp-x) - var(--rp-size) / 2);
  top: calc(var(--rp-y) - var(--rp-size) / 2);
  width: var(--rp-size);
  height: var(--rp-size);
  border-radius: 50%;
  background: #0f5f8a;
  opacity: 0;
  pointer-events: none;
  animation: rp-trip-fade .3s linear;
}
@keyframes rp-trip-wave {
  from { transform: scale(0); opacity: .26; }
  to { transform: scale(1); opacity: 0; }
}
@keyframes rp-trip-fade {
  from { opacity: .18; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-trip__ink {
    animation: rp-trip-wave .75s ease-out;
  }
}

Bounded ripple on round icon buttons

  • border-radius: 50%
  • centered wave
  • aria-pressed

Toggle the text formats.

On a 44px icon button a centered wave looks the same as a click-origin one, so the ripple always starts in the middle. border-radius: 50% with overflow: hidden keeps it a clean circle, and aria-pressed carries the toggle state the color shows.

View code
.rp-tools {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 6px;
  border-radius: 999px;
  background: #fff;
  box-shadow: 0 8px 26px rgba(31, 41, 55, .12);
}
.rp-tools__btn {
  position: relative;
  z-index: 0;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  overflow: hidden;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: #1f2937;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-tools__btn:hover {
  background: #f1f3f6;
}
.rp-tools__btn[aria-pressed="true"] {
  background: #dbe4ff;
  color: #1d3fb8;
}
.rp-tools__btn svg {
  position: relative;
  z-index: 1;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.rp-tools__btn:focus-visible {
  outline: 3px solid #1d3fb8;
  outline-offset: 2px;
}
.rp-tools__sep {
  width: 1px;
  height: 24px;
  margin: 0 4px;
  background: #dfe3ea;
}
.rp-tools__ink {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: currentColor;
  opacity: 0;
  pointer-events: none;
  animation: rp-tools-fade .3s linear;
}
@keyframes rp-tools-wave {
  from { transform: scale(0); opacity: .28; }
  to { transform: scale(1.4); opacity: 0; }
}
@keyframes rp-tools-fade {
  from { opacity: .2; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-tools__btn {
    transition: background-color .2s ease;
  }
  .rp-tools__ink {
    animation: rp-tools-wave .45s ease-out;
  }
}

Unbounded ripple on a floating action button

  • overflow: visible
  • unbounded wave
  • createElement

Notes

  • Studio rent, OctoberYesterday
  • Type specimen ideasMon

Press the plus button to add a note.

An unbounded ripple is the opposite clipping rule: the FAB keeps overflow visible, the ink starts at its center and grows past the edge as a soft circle before fading. The surrounding card leaves room for it, since the stage itself clips.

View code
.rp-notes {
  position: relative;
  width: min(320px, 100%);
  min-height: 270px;
  padding: 18px 18px 88px;
  border-radius: 20px;
  background: #f8f5ec;
  color: #1f2a24;
  box-shadow: 0 10px 30px rgba(31, 42, 36, .1);
}
.rp-notes__title {
  margin: 0 0 10px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.4rem;
  font-weight: 600;
  letter-spacing: -.01em;
  color: #1f2a24;
}
.rp-notes__list {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.rp-notes__list li {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  margin: 0;
  padding: 11px 14px;
  border-radius: 12px;
  background: #fff;
  font-size: .9rem;
}
.rp-notes__name {
  overflow: hidden;
  font-weight: 600;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.rp-notes__when {
  flex: none;
  font-size: .8rem;
  color: #56625b;
}
.rp-notes__fab {
  position: absolute;
  right: 22px;
  bottom: 22px;
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  overflow: visible;
  padding: 0;
  border: 0;
  border-radius: 16px;
  background: #1b5e4a;
  color: #fff;
  cursor: pointer;
  box-shadow: 0 6px 16px rgba(27, 94, 74, .35);
  -webkit-tap-highlight-color: transparent;
}
.rp-notes__fab svg {
  position: relative;
  z-index: 1;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
}
.rp-notes__fab:focus-visible {
  outline: 3px solid #1b5e4a;
  outline-offset: 4px;
}
.rp-notes__ink {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 120px;
  height: 120px;
  margin: -60px 0 0 -60px;
  border-radius: 50%;
  background: #1b5e4a;
  opacity: 0;
  pointer-events: none;
  animation: rp-notes-fade .3s linear;
}
.rp-notes__status {
  position: absolute;
  left: 18px;
  bottom: 38px;
  max-width: calc(100% - 110px);
  margin: 0;
  font-size: .8rem;
  color: #3d4a43;
}
@keyframes rp-notes-wave {
  from { transform: scale(.4); opacity: .32; }
  to { transform: scale(1); opacity: 0; }
}
@keyframes rp-notes-fade {
  from { opacity: .18; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-notes__ink {
    animation: rp-notes-wave .55s ease-out;
  }
}

Ripple halo on checkboxes and radios

  • ::before scale
  • :has()
  • appearance: none

Notifications

Email digest

Press and hold a row, or Tab through the controls.

Selection controls get a round halo instead of a clipped wave. A ::before circle behind each native input scales up from half size while the row is pressed, shows faintly on hover and stays visible on keyboard focus, which :has(:focus-visible) detects. The inputs stay real checkboxes and radios.

View code
.rp-pref {
  width: min(320px, 100%);
  margin: 0;
  padding: 18px 18px 12px;
  border-radius: 16px;
  background: #fff;
  color: #1e1b2e;
  box-shadow: 0 10px 28px rgba(30, 27, 46, .09);
}
.rp-pref__title,
.rp-pref__legend {
  margin: 0 0 4px;
  padding: 0;
  font-family: inherit;
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0;
  color: #1e1b2e;
}
.rp-pref__set {
  margin: 10px 0 0;
  padding: 10px 0 0;
  border: 0;
  border-top: 1px solid #ece9f3;
}
.rp-pref__legend {
  float: left;
  width: 100%;
}
.rp-pref__row {
  display: flex;
  clear: both;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  font-size: .95rem;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-pref__ctl {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: 40px;
  height: 40px;
}
.rp-pref__ctl::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: #6d28d9;
  opacity: 0;
  transform: scale(.5);
  transition: opacity .2s ease;
}
.rp-pref__ctl input {
  position: relative;
  z-index: 1;
  width: 20px;
  height: 20px;
  margin: 0;
  border: 2px solid #5b5870;
  border-radius: 5px;
  background: #fff;
  cursor: pointer;
  appearance: none;
}
.rp-pref__ctl input[type="radio"] {
  border-radius: 50%;
}
.rp-pref__ctl input:checked {
  border-color: #6d28d9;
  background: #6d28d9;
}
.rp-pref__ctl input[type="radio"]:checked {
  background: #6d28d9;
  box-shadow: inset 0 0 0 3px #fff;
}
.rp-pref__ctl svg {
  position: absolute;
  z-index: 2;
  fill: none;
  stroke: #fff;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  pointer-events: none;
}
.rp-pref__ctl:has(input:checked) svg {
  opacity: 1;
}
.rp-pref__row:hover .rp-pref__ctl::before {
  opacity: .08;
  transform: scale(1);
}
.rp-pref__ctl:has(input:focus-visible)::before {
  opacity: .16;
  transform: scale(1);
}
.rp-pref__row:active .rp-pref__ctl::before {
  opacity: .26;
  transform: scale(1);
}
.rp-pref__ctl input:focus-visible {
  outline: 2px solid #6d28d9;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .rp-pref__ctl::before {
    transition: opacity .2s ease, transform .25s ease-out;
  }
}

Pulse rings and ripple backgrounds

Ripples that are not tied to a press. They draw the eye to a notification or a location, so they run a few cycles and stop, and the looping hero background has a pause button.

Water-drop pulse rings on a notification

  • animation-iteration-count: 3
  • staggered rings
  • restart class

Studio dashboard

New comments and approvals show up on the bell.

Simulate a message to replay the rings, then open the bell.

Two rings leave the bell half a cycle apart, like drops on water, and stop after three iterations, so a new message gets attention without a badge that pulses forever. The script restarts them by removing the class, reading offsetWidth and adding it back.

View code
.rp-bell {
  display: grid;
  gap: 14px;
  width: min(330px, 100%);
  padding: 16px 18px 20px;
  border-radius: 18px;
  background: #fff;
  color: #1f2430;
  box-shadow: 0 10px 30px rgba(31, 36, 48, .09);
}
.rp-bell__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-bottom: 12px;
  border-bottom: 1px solid #eef0f4;
}
.rp-bell__app {
  margin: 0;
  font-weight: 700;
}
.rp-bell__wrap {
  position: relative;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
}
.rp-bell__ring {
  position: absolute;
  inset: 4px;
  border: 2px solid #e0413a;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}
.rp-bell__btn {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #f3f4f7;
  color: #1f2430;
  cursor: pointer;
}
.rp-bell__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.rp-bell__badge {
  position: absolute;
  right: -2px;
  top: -2px;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  border: 2px solid #fff;
  border-radius: 10px;
  background: #c62f28;
  color: #fff;
  font-size: .7rem;
  font-weight: 700;
  line-height: 16px;
  text-align: center;
}
.rp-bell__badge[hidden] {
  display: none;
}
.rp-bell__btn:focus-visible,
.rp-bell__sim:focus-visible {
  outline: 3px solid #1f2430;
  outline-offset: 3px;
}
.rp-bell__text {
  margin: 0;
  font-size: .9rem;
  line-height: 1.5;
  color: #4f5666;
}
.rp-bell__sim {
  justify-self: start;
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid #d6dae2;
  border-radius: 10px;
  background: #fff;
  color: #1f2430;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.rp-bell__sim:hover {
  background: #f6f7f9;
}
@keyframes rp-bell-drop {
  from { transform: scale(1); opacity: .7; }
  to { transform: scale(2.1); opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-bell--ping .rp-bell__ring {
    animation: rp-bell-drop 1.4s cubic-bezier(.2, .6, .35, 1) 3;
  }
  .rp-bell--ping .rp-bell__ring + .rp-bell__ring {
    animation-delay: .7s;
  }
}

Radar pulse on a map pin

  • finite iterations
  • scale + opacity
  • restart on demand
Street map of a riverside neighborhood with two parks

You are here

Corner of Mill Lane, updated just now

Press Locate me to send another pulse.

A location pin sends a sonar ring four times when the position updates, then rests as a solid dot, because a pin that pulses forever competes with everything else on the map. Locate me restarts the cycle. Each ring only scales and fades, so the map underneath is never repainted.

View code
.rp-map {
  position: relative;
  width: min(440px, 100%);
  overflow: hidden;
  border-radius: 18px;
  background: #efe9dd;
  box-shadow: 0 10px 30px rgba(40, 34, 20, .12);
}
.rp-map__img {
  display: block;
  width: 100%;
  height: 230px;
  object-fit: cover;
}
.rp-map__pin {
  position: absolute;
  left: 50%;
  top: 98px;
  width: 22px;
  height: 22px;
  margin-left: -11px;
  border: 4px solid #fff;
  border-radius: 50%;
  background: #2457d6;
  box-shadow: 0 2px 6px rgba(20, 40, 90, .35);
}
.rp-map__wave {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 22px;
  height: 22px;
  margin: -11px 0 0 -11px;
  border-radius: 50%;
  background: #2457d6;
  opacity: 0;
  pointer-events: none;
}
.rp-map__card {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 2px 12px;
  padding: 14px 16px;
  background: #fff;
  color: #1c2333;
}
.rp-map__place {
  margin: 0;
  font-weight: 700;
}
.rp-map__meta {
  grid-row: 2;
  margin: 0;
  font-size: .82rem;
  color: #525b6d;
}
.rp-map__btn {
  grid-row: 1 / 3;
  grid-column: 2;
  min-height: 44px;
  padding: 0 14px;
  border: 0;
  border-radius: 10px;
  background: #2457d6;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.rp-map__btn:hover {
  background: #1c47b4;
}
.rp-map__btn:focus-visible {
  outline: 3px solid #1c2333;
  outline-offset: 3px;
}
@keyframes rp-map-sonar {
  from { transform: scale(1); opacity: .45; }
  to { transform: scale(6); opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-map--ping .rp-map__wave {
    animation: rp-map-sonar 1.8s ease-out 4;
  }
  .rp-map--ping .rp-map__wave + .rp-map__wave {
    animation-delay: .5s;
  }
}
@media (max-width: 380px) {
  .rp-map__card {
    grid-template-columns: 1fr;
  }
  .rp-map__btn {
    grid-row: 3;
    grid-column: 1;
    justify-self: start;
    margin-top: 8px;
  }
}

Ripple ring background hero with a pause control

  • concentric rings
  • animation-play-state
  • aria-pressed

Tidewater

Focus sounds that roll in like the tide

Long, slow soundscapes for deep work, built to fade so you never notice the loop.

The rings only move with motion allowed. Pause stops them.

Five rings expand from one point with staggered delays, so the pattern loops without a visible restart. A looping background needs a way to stop it: the pause button flips animation-play-state through a class and reports its state with aria-pressed. With reduced motion the rings render as a still pattern and the button hides.

View code
.rp-tide {
  position: relative;
  display: grid;
  align-items: center;
  min-height: 420px;
  overflow: hidden;
  padding: clamp(28px, 6vw, 64px);
  background: #0b2233;
  color: #eaf4f8;
}
.rp-tide__rings {
  position: absolute;
  right: 12%;
  top: 50%;
  width: 0;
  height: 0;
}
.rp-tide__rings span {
  position: absolute;
  left: -60px;
  top: -60px;
  width: 120px;
  height: 120px;
  border: 1.5px solid #6fd3c7;
  border-radius: 50%;
  opacity: .5;
}
.rp-tide__rings span:nth-child(1) { transform: scale(1); opacity: .55; }
.rp-tide__rings span:nth-child(2) { transform: scale(2); opacity: .42; }
.rp-tide__rings span:nth-child(3) { transform: scale(3); opacity: .3; }
.rp-tide__rings span:nth-child(4) { transform: scale(4); opacity: .2; }
.rp-tide__rings span:nth-child(5) { transform: scale(5); opacity: .1; }
.rp-tide__copy {
  position: relative;
  z-index: 1;
  max-width: 460px;
}
.rp-tide__brand {
  margin: 0 0 14px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.1rem;
  font-weight: 600;
  color: #6fd3c7;
}
.rp-tide__title {
  margin: 0 0 12px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(1.8rem, 4.5vw, 2.8rem);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -.02em;
  color: #eaf4f8;
}
.rp-tide__text {
  max-width: 38ch;
  margin: 0 0 24px;
  font-size: 1rem;
  line-height: 1.55;
  color: #b9cdd6;
}
.rp-tide__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.rp-tide__cta,
.rp-tide__pause {
  min-height: 46px;
  padding: 0 20px;
  border-radius: 999px;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.rp-tide__cta {
  border: 0;
  background: #6fd3c7;
  color: #062126;
}
.rp-tide__pause {
  border: 1px solid #50707f;
  background: rgba(11, 34, 51, .7);
  color: #eaf4f8;
}
.rp-tide__pause:hover {
  border-color: #8fb0be;
}
.rp-tide__cta:focus-visible,
.rp-tide__pause:focus-visible {
  outline: 3px solid #eaf4f8;
  outline-offset: 3px;
}
@keyframes rp-tide-spread {
  from { transform: scale(.4); opacity: .6; }
  to { transform: scale(6); opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-tide__rings span {
    animation: rp-tide-spread 10s linear infinite;
  }
  .rp-tide__rings span:nth-child(2) { animation-delay: -2s; }
  .rp-tide__rings span:nth-child(3) { animation-delay: -4s; }
  .rp-tide__rings span:nth-child(4) { animation-delay: -6s; }
  .rp-tide__rings span:nth-child(5) { animation-delay: -8s; }
  .rp-tide--paused .rp-tide__rings span {
    animation-play-state: paused;
  }
}
@media (prefers-reduced-motion: reduce) {
  .rp-tide__pause {
    display: none;
  }
}
@media (max-width: 640px) {
  .rp-tide__rings {
    right: 10%;
    top: 22%;
  }
}

Color, dark mode and reduced motion

The ink itself: light ink on dark surfaces, one ripple rule that takes its color from currentColor with color-mix(), and a ripple that turns into a quick color flash when the user asks for less motion.

Light ink ripple in dark mode

  • currentColor ink
  • dark surface
  • aria-pressed

Harbour Lights

Low Tide Tapes

Press the player controls.

On a dark surface the ink has to be lighter than the button, so every ripple uses currentColor: light ink on the dark icon buttons and dark ink on the light play button, without a second set of rules. Peak opacity stays low enough that the icons never wash out.

View code
.rp-play {
  display: grid;
  justify-items: center;
  width: min(320px, 100%);
  padding: 22px 18px 18px;
  border: 1px solid #2a2e37;
  border-radius: 22px;
  background: #15171c;
  color: #f2f3f5;
}
.rp-play__art {
  display: grid;
  place-items: center;
  width: 128px;
  height: 128px;
  margin-bottom: 16px;
  border-radius: 18px;
  background: #24476b;
}
.rp-play__art span {
  width: 64px;
  height: 64px;
  border: 10px solid #e9c46a;
  border-radius: 50%;
  box-shadow: 0 0 0 10px #24476b, 0 0 0 13px #e9c46a;
}
.rp-play__track {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.25rem;
  font-weight: 600;
  letter-spacing: 0;
  color: #f2f3f5;
}
.rp-play__artist {
  margin: 2px 0 14px;
  font-size: .88rem;
  color: #a3a9b6;
}
.rp-play__bar {
  width: 100%;
  height: 4px;
  margin-bottom: 14px;
  border-radius: 4px;
  background: #343945;
}
.rp-play__bar span {
  display: block;
  width: 38%;
  height: 100%;
  border-radius: 4px;
  background: #f2f3f5;
}
.rp-play__controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
}
.rp-play__btn {
  position: relative;
  z-index: 0;
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  overflow: hidden;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: #e6e8ec;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-play__btn svg {
  position: relative;
  z-index: 1;
  fill: currentColor;
}
.rp-play__btn--toggle svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.rp-play__btn--toggle[aria-pressed="true"] {
  color: #e9c46a;
}
.rp-play__btn--main {
  width: 62px;
  height: 62px;
  background: #f2f3f5;
  color: #15171c;
}
.rp-play__icon-pause,
.rp-play--playing .rp-play__icon-play {
  display: none;
}
.rp-play--playing .rp-play__icon-pause {
  display: block;
}
.rp-play__btn:hover {
  background: #262a33;
}
.rp-play__btn--main:hover {
  background: #ffffff;
}
.rp-play__btn:focus-visible {
  outline: 3px solid #e9c46a;
  outline-offset: 2px;
}
.rp-play__ink {
  position: absolute;
  left: calc(var(--rp-x) - var(--rp-size) / 2);
  top: calc(var(--rp-y) - var(--rp-size) / 2);
  width: var(--rp-size);
  height: var(--rp-size);
  border-radius: 50%;
  background: currentColor;
  opacity: 0;
  pointer-events: none;
  animation: rp-play-fade .3s linear;
}
@keyframes rp-play-wave {
  from { transform: scale(0); opacity: .26; }
  to { transform: scale(1); opacity: 0; }
}
@keyframes rp-play-fade {
  from { opacity: .18; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-play__ink {
    animation: rp-play-wave .55s ease-out;
  }
}

Ripple color from currentColor with color-mix()

  • color-mix()
  • currentColor
  • @supports fallback

Show topics

Toggle the topic filters.

One rule styles four chips in four colors. Each chip only sets color, and color-mix() derives the tint, the border, the selected fill and the ripple ink from currentColor. Browsers without color-mix() fall back to plain currentColor at a lower opacity through @supports.

View code
.rp-chips {
  width: min(360px, 100%);
  padding: 20px;
  border-radius: 16px;
  background: #fff;
  color: #1f2430;
  box-shadow: 0 10px 28px rgba(31, 36, 48, .08);
}
.rp-chips__title {
  margin: 0 0 12px;
  font-weight: 700;
}
.rp-chips__row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.rp-chips__chip {
  position: relative;
  z-index: 0;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 40px;
  overflow: hidden;
  padding: 0 16px;
  border: 1.5px solid currentColor;
  border-radius: 999px;
  background: #fff;
  font: inherit;
  font-size: .92rem;
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-chips__chip[aria-pressed="true"]::before {
  content: "\2713";
  font-weight: 700;
}
.rp-chips__chip:focus-visible {
  outline: 3px solid currentColor;
  outline-offset: 2px;
}
.rp-chips__ink {
  position: absolute;
  z-index: -1;
  left: calc(var(--rp-x) - var(--rp-size) / 2);
  top: calc(var(--rp-y) - var(--rp-size) / 2);
  width: var(--rp-size);
  height: var(--rp-size);
  border-radius: 50%;
  background: currentColor;
  opacity: .22;
  pointer-events: none;
  animation: rp-chips-fade .3s linear;
}
@supports (color: color-mix(in srgb, red 50%, blue)) {
  .rp-chips__chip {
    border-color: color-mix(in srgb, currentColor 40%, #fff);
    background: color-mix(in srgb, currentColor 5%, #fff);
  }
  .rp-chips__chip:hover {
    background: color-mix(in srgb, currentColor 10%, #fff);
  }
  .rp-chips__chip[aria-pressed="true"] {
    border-color: currentColor;
    background: color-mix(in srgb, currentColor 16%, #fff);
  }
  .rp-chips__ink {
    background: color-mix(in srgb, currentColor 32%, transparent);
    opacity: 1;
  }
}
@keyframes rp-chips-wave {
  from { transform: scale(0); }
  to { opacity: 0; }
}
@keyframes rp-chips-fade {
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-chips__ink {
    animation: rp-chips-wave .55s ease-out;
  }
}

Reduced motion: ripple becomes a color flash

  • prefers-reduced-motion
  • matchMedia
  • background flash

Profile

Display nameInes Moreau

Time zoneEurope/Lisbon

Save once, then tick the preview box and save again.

Under prefers-reduced-motion: reduce the script skips the expanding span and the button flashes a lighter background for 220ms instead, so the press is still confirmed without anything growing or moving. The checkbox previews that branch on any device, and the status line carries the result in both cases.

View code
.rp-calm {
  display: grid;
  gap: 10px;
  width: min(330px, 100%);
  padding: 20px;
  border-radius: 16px;
  background: #fff;
  color: #17252b;
  box-shadow: 0 10px 28px rgba(23, 37, 43, .08);
}
.rp-calm__title {
  margin: 0 0 2px;
  font-family: inherit;
  font-size: 1.05rem;
  font-weight: 700;
  letter-spacing: 0;
  color: #17252b;
}
.rp-calm__field {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin: 0;
  padding: 10px 12px;
  border-radius: 10px;
  background: #f3f6f7;
  font-size: .9rem;
}
.rp-calm__field span {
  color: #4f5f66;
}
.rp-calm__sim {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 40px;
  font-size: .9rem;
  cursor: pointer;
}
.rp-calm__check {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: #0e7490;
}
.rp-calm__check:focus-visible {
  outline: 3px solid #0e7490;
  outline-offset: 2px;
}
.rp-calm__btn {
  position: relative;
  z-index: 0;
  min-height: 48px;
  overflow: hidden;
  border: 0;
  border-radius: 12px;
  background: #0e7490;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.rp-calm__btn:focus-visible {
  outline: 3px solid #17252b;
  outline-offset: 3px;
}
.rp-calm__label {
  position: relative;
  z-index: 1;
}
.rp-calm__btn.rp-calm--flash {
  animation: rp-calm-flash .22s linear;
}
.rp-calm__ink {
  position: absolute;
  left: calc(var(--rp-x) - var(--rp-size) / 2);
  top: calc(var(--rp-y) - var(--rp-size) / 2);
  width: var(--rp-size);
  height: var(--rp-size);
  border-radius: 50%;
  background: #fff;
  opacity: 0;
  pointer-events: none;
  animation: rp-calm-fade .3s linear;
}
.rp-calm__status {
  min-height: 1.4em;
  margin: 0;
  font-size: .85rem;
  color: #0b5a70;
}
@keyframes rp-calm-flash {
  from { background-color: #3aa0b8; }
  to { background-color: #0e7490; }
}
@keyframes rp-calm-wave {
  from { transform: scale(0); opacity: .32; }
  to { transform: scale(1); opacity: 0; }
}
@keyframes rp-calm-fade {
  from { opacity: .2; }
  to { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .rp-calm__ink {
    animation: rp-calm-wave .6s ease-out;
  }
}

What Is the CSS Ripple Effect?

Input goes in, a circle grows out from wherever the finger or cursor landed, and then it fades. That’s the whole effect, and it tells the user the press registered right where they made it.

Material Design called it the “ink ripple”, and it works as a micro-interaction that tells the user the interface registered the input.

Material Components for the web still ships it as a reusable package, although the project is no longer actively maintained. Its Button, FAB, Checkbox and Radio components all use it (MDC Web documentation).

Most ripples are bounded, meaning they’re clipped to the element edges, which is the default for buttons, cards and list rows. Unbounded ripples flow outside the element in a circle and suit icon buttons and selection controls such as checkboxes and radios (the MDC Web ripple docs and the mwc-ripple package document both modes).

A few other effects get called ripples without being one. A hover or :active color swap has no expanding shape and no origin point. A pulse or sonar loop repeats with animation-iteration-count: infinite and needs no input at all. A water ripple built from an SVG filter or WebGL distorts an image and gives no feedback on input.

The hover effects most buttons already carry only say an element is clickable. The ripple is the one that shows where the click landed.

How the Ripple Animation Works: Scale, Opacity and Timing

Two properties do the work. transform: scale() takes a small circle to full size while opacity drops to 0.

The circle starts at scale(0) on the pointer position and ends at a diameter that reaches the farthest corner of the element. Its transform-origin stays at the default center because the span itself is positioned around the click.

One CSS keyframes rule carries both changes: @keyframes ripple { to { transform: scale(1); opacity: 0; } }. An ease-out curve fits the motion, since the wave moves fast at first and settles as it fades.

Material splits the effect in two. In Jetpack Compose, a press starts a ripple animation, while hover and focus show a fixed state layer whose alpha varies by interaction (Android Developers documentation).

The html.it button tutorial uses 600 ms with linear easing, and the GeeksforGeeks ripple guide, updated July 2025, uses 1 s. On opacity, the mwc-ripple README from Material Components sets a default press value of 0.12 against 0.04 for hover.

Tutorials span 600 ms to 1 s, and the Material package gives a press three times the opacity of a hover. No source fixes one correct duration, so treat 600 ms as a starting point and tune it on a real phone.

Which event should start the ripple?

Use pointerdown for the JavaScript version and keep :active for the centered CSS version.

The click event fires after release, so on touch screens the wave starts late. pointerdown fires on contact for mouse, touch and pen, which is the closest match to Material’s press response. :active is pure CSS, but an animation tied to it stops the moment the press ends.

Which Ripple Method Fits: Pure CSS, JavaScript, Paint API or a Library?

Use a JavaScript span for most pages. Pick the pure CSS gradient only when a centered ripple is acceptable, and leave the Paint API out of production code.

MethodStarts at click pointNeeds scriptBest fit
Pure CSS gradientNo, always centeredNoSmall and icon buttons
JavaScript spanYesYesCards, list rows, wide buttons
Paint API workletYes, with coordinates passed inYesChromium-only projects
Framework libraryYes, by defaultYes, via the libraryProjects already on that framework

Compare the element’s width with how far a finger can land from its center. On a 40 px icon button a centered wave looks the same as a click-origin one. On a full-width list row it floats away from the tap.

Pure CSS gradient

It needs no extra DOM nodes and no script, so a stylesheet alone is enough. The catch is the origin, which stays centered because CSS cannot read pointer coordinates (finnhvman, CodePen).

JavaScript span

The JavaScript version appends a span at the pointer position on each press and deletes it afterward.

You get the true click origin on any element size, plus full control over color, duration and easing. In return you write cleanup code for every span, you need a positioned and clipped container, and spans leak when the cleanup event never fires.

CSS Paint API

The caniuse tables, checked in October 2026, list the CSS Painting API as supported from Chrome 65, Edge 79 and Opera 52, unsupported in Firefox and Safari on iOS, and disabled by default in desktop Safari.

web.dev’s Houdini guide calls Safari support partial in one sentence and lists Chromium-based browsers only in the next, while caniuse marks Safari as disabled by default. Either way the result is the same: Safari and Firefox users get no worklet without a polyfill.

That fallback is weak too. GoogleChromeLabs archived the css-paint-polyfill repository on 19 April 2026 (GitHub), so it is read-only.

On the plus side, one worklet draws the circle with a canvas-style API and reads custom properties, so it can serve many elements.

A feedback effect should work for every user, and this method fails in two of the three major engines. Skip it.

Framework library

A library handles repeated presses, keyboard focus and unbounded variants for you. The cost is a dependency and its defaults.

MUI ships a TouchRipple inside its ButtonBase component, and Material Components for the web offers the standalone @material/ripple package for plain pages, though that project is no longer actively maintained.

How to Build a Ripple With Pure CSS

A pure CSS ripple pairs an oversized radial-gradient() with a background-size change on :active. It needs no script, and the wave always starts at the center of the element.

Gradients cannot transition into one another, so the trick animates the size of a single fixed gradient instead. The technique comes from finnhvman’s CodePen write-up, which uses a 0.5 s transition, and 1stWebDesigner’s pure CSS ripple button tutorial uses a 1 s transition. The colors and the 0.8 s duration below are example values.

.ripple {
  background-color: #1e88e5;
  background-position: center;
  transition: background 0.8s;
}

.ripple:hover {
  background: #47a7f5 radial-gradient(circle, transparent 1%, #47a7f5 1%) center / 15000%;
}

.ripple:active {
  background-color: #6eb9f7;
  background-size: 100%;
  transition: background 0s;
}

At rest the button has a solid background-color and a 0.8 s background transition. On hover the gradient is drawn at 15000%, so its transparent 1% center is larger than the button and only the hover color shows. The active state snaps it to background-size: 100% with a 0 s transition, which shrinks the transparent dot to a point.

On release, the size transition expands the dot and reveals the pressed color as a growing circle. That is why the ripple plays when the click or tap ends, not when it starts (finnhvman, CodePen).

Stop positions are easy to get wrong by hand, and a CSS gradient generator shows the circle before the code reaches the stylesheet.

The origin stays centered, and a long press shows only the small dot. Touch browsers can also keep :hover applied after a tap. A pseudo-element variant that animates ::after on :active has its own problem, since it stops the instant the press ends and a quick tap shows a fragment of the wave.

How to Start the Ripple at the Click Point With JavaScript

Create a span on pointerdown, center it on the pointer inside the element, animate it, and remove it when the animation ends.

  1. Give the container position: relative and overflow: hidden, so the span is placed and clipped inside it.
  2. Listen for pointerdown on the element.
  3. Read its box with getBoundingClientRect().
  4. Size the span to twice the box diagonal.
  5. Set left and top to the pointer position minus the box origin minus half the span size.
  6. Remove the span on animationend.

The stylesheet side looks like this:

.ripple-btn {
  position: relative;
  overflow: hidden;
}

.ripple {
  position: absolute;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.4);
  transform: scale(0);
  animation: ripple 600ms ease-out;
  pointer-events: none;
}

@keyframes ripple {
  to {
    transform: scale(1);
    opacity: 0;
  }
}

And the script:

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

button.addEventListener('pointerdown', (event) => {
  const rect = button.getBoundingClientRect();
  const size = Math.hypot(rect.width, rect.height) * 2;
  const ripple = document.createElement('span');

  ripple.className = 'ripple';
  ripple.style.width = ripple.style.height = size + 'px';
  ripple.style.left = event.clientX - rect.left - size / 2 + 'px';
  ripple.style.top = event.clientY - rect.top - size / 2 + 'px';

  button.appendChild(ripple);
  ripple.addEventListener('animationend', () => ripple.remove());
});

MDN documents that rectangle values are relative to the viewport and change as the page scrolls. Because clientX uses the same viewport space, the subtraction stays correct at any scroll position.

Tutorials often size the span with the larger of width and height. A click near one corner then leaves the opposite corner uncovered, because that radius is shorter than the diagonal.

Cleanup can fail too. MDN states that animationend does not fire when an animation is aborted, for example when the element is removed.

A span whose animation never runs, such as under an animation: none rule, never fires the event either, so spans pile up in the DOM. Skip creating the span in that case.

Material Components’ ripple (version 14.0.0) passes foreground size, scale, left and top to CSS through custom properties. Copying that pattern keeps the numbers in JavaScript and the animation in the stylesheet.

Why Does the Ripple Spill Outside or Fail to Appear?

Most ripple bugs come from the container, not the animation. Missing clipping, a stacking problem or a browser clipping bug is usually behind it.

SymptomCauseFix
Wave spills past the edgeContainer lacks overflow: hiddenAdd it, with position: relative
Corners poke through on rounded buttons (Safari)Animated child ignores the border-radius clipMake the container a stacking context
Rendering glitches on animated spans (reported in some Safari versions)Container uses overflow: clipSwitch to overflow: hidden
Label looks washed outSpan paints above the textRaise the label with position: relative and a z-index, or lower the ripple alpha
Several waves layer on fast tapsEvery press adds a spanLet all finish, or remove the previous span first

Ionic documents the clipping rule from both sides. Surfaces for bounded ripples take overflow: hidden, while unbounded ones need overflow: visible so the circle can leave the box.

Rounded corners in Safari

A WebKit bug lets an animated child ignore the parent’s border-radius clip. A widely shared workaround note, tested on iOS 14.4, calls it an old bug that will never be fixed.

WebKit’s own tracker disagrees for at least one case. Bug 231360, which concerned an animated pseudo-element with translateX, was closed as “Configuration Changed”. A contributor could not reproduce it in Safari Technology Preview 165, and a WebKit engineer confirmed that an earlier border-radius and composited-layer fix had resolved it. The last change was logged on 9 March 2023.

WebKit bug 257794, still open, reports animated transforms rendering wrongly inside an overflow: clip container in Safari 16.5 and on iPhone. Changing the container to overflow: hidden removed the problem in that report.

The workarounds from the gist all force a stacking context on the clipping element:

  • z-index: 0 (with a positioned element)
  • transform: translateZ(0)
  • mask-image: radial-gradient(white, black)
  • will-change: transform, used sparingly

The gist’s author prefers transform or z-index as the least invasive. Start with z-index: 0.

Round and irregular shapes

Circles and pills need only a border-radius on the container, which a CSS border radius generator previews quickly.

Logos and other irregular outlines need clip-path on the container instead. FreeFrontend’s gallery lists a logo ripple built this way, with the polygon points set in clip-path.

How Much Does a Ripple Cost in Performance?

Cost follows the properties being animated, not the ripple as an idea.

web.dev’s compositor guide, checked in October 2026, names two properties that skip layout and paint: transform and opacity. A span ripple animates exactly those.

The gradient method animates background-size, which sits outside that pair, so each frame repaints the element. One small button will not show it. A long list of rows will.

If profiling shows jank, promote the span and not the button. Put will-change: transform in the ripple class, because the span is created by script just before the animation and disappears afterward. Skip it when the ripple already runs smoothly.

Delegate the listener too. Attach one pointerdown handler to the list and find the pressed row with event.target.closest(), instead of one handler per row. And keep promoted layers few, since web.dev warns that every one costs memory and management.

MDN calls will-change a last resort and lists guidelines that include not applying it to too many elements and not using it as premature optimization. A stylesheet rule on every row of a long list breaks both.

How to check it in Chrome DevTools

  1. Open the Rendering tab and turn on Paint flashing.
  2. Press the ripple target and watch for green flashes, which mark repainted regions.
  3. Record a short trace in the Performance panel and look for Layout and Paint entries on each frame.

Layout entries during the press mean something besides transform and opacity is changing.

How to Make a Ripple Accessible

Replace the scale with a short fade when the user asks for reduced motion, and give keyboard users a focus style that does not depend on the ripple.

WCAG 2.3.3, Animation from Interactions, is a web accessibility criterion at Level AAA, introduced in WCAG 2.1. It says motion triggered by interaction can be disabled unless it is necessary to the function or the information (W3C).

MDN explains that prefers-reduced-motion: reduce means the user prefers an interface that removes, reduces or replaces motion, and it warns that scaling or panning large objects can trigger vestibular disorders. Its own example swaps a scale for a “dissolve”. Smashing Magazine’s guide on motion preferences makes the same point: reduced motion doesn’t have to mean no motion, so subtle feedback can stay.

A ripple holds both kinds of motion. The scale is motion, the fade is not, so under reduce you drop the scale and keep the fade.

@media (prefers-reduced-motion: reduce) {
  .ripple {
    transform: scale(1);
    animation: ripple-fade 200ms linear;
  }
}

@keyframes ripple-fade {
  from { opacity: 0.6; }
  to { opacity: 0; }
}

Place the block after the base .ripple rule. Replace the animation instead of setting it to none, so animationend still fires and the cleanup listener runs.

Keyboard focus

Enter and Space never fire pointerdown, so keyboard users see no wave at all. That leaves you writing your own focus style. MUI’s ButtonBase documentation warns that disabling the ripple leaves no default :focus-visible styling, and a custom ripple never draws a focus indicator either.

.ripple-btn:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

Check the label’s color contrast at the ripple’s peak opacity, because a light overlay washes out text on a mid-tone button.

How MUI, Angular Material and Vuetify Handle the Ripple

All three ship a built-in ripple, and each exposes one switch to center it and another to turn it off.

LibraryRipple APICentered rippleTurn off
MUIButtonBase with TouchRipplecenterRippledisableRipple, disableTouchRipple
Angular MaterialmatRipple directivematRippleCenteredmatRippleDisabled
Vuetifyv-ripple directivecenter optionripple prop on components

In MUI, a focusRipple prop adds a keyboard focus ripple, and TouchRippleProps configures the ripple element. Angular Material launches manual ripples at given coordinates and reads global defaults from the MAT_RIPPLE_GLOBAL_OPTIONS injection token.

Vuetify’s v-btn and other components come with it built in, and the directive works on any block-level element. A helper class changes the color.

Other options

Google’s Material Web project offers an md-ripple custom element. Its GitHub tracker carries an open report of a ripple that stays visible after focus is lost inside a swiper widget.

Waves.js attaches with Waves.attach() or the waves-effect class, and then you call Waves.init(). npm lists version 0.7.6 of the node-waves package under a 2014-2018 copyright line, so treat it as a legacy choice.

Ionic’s ion-ripple-effect takes a type of bounded (the default, expanding from the click position) or unbounded (starting at the center and overflowing the container). It needs a relatively positioned parent with the ion-activatable class, and it exposes addRipple(x, y) for manual starts.

Library or custom code?

If the project already runs on one of these frameworks, use its built-in ripple, since it’s wired into every button, tab and chip.

On a plain HTML page, custom code wins. Pulling in a UI component library for one effect costs far more than the short script shown earlier.

Never stack a custom ripple on a library button. The button already has one.

CSS Ripple Effect FAQ

Which browsers support a ripple effect built with CSS and JavaScript?

The span method relies on two APIs that MDN lists as Baseline widely available: getBoundingClientRect, and animationend, which has worked in every browser since December 2019.

Only the Paint API route falls outside that.

How do I change the ripple color?

Change the background of the ripple span, using a semi-transparent value such as rgba(255, 255, 255, 0.4) so the button color shows through.

A custom property like –ripple-color lets each button override it. In the gradient version, edit the color stops instead.

Can I build the ripple with Tailwind CSS?

Yes, for the styling. Utilities handle the container (relative, overflow-hidden) and the span (absolute, rounded-full, a translucent background), and the keyframes go once into the Tailwind theme: the config file in v3, or an @theme block in v4.

The pointer-position script stays plain JavaScript, because utilities cannot read coordinates.

How do I add the ripple to a React component?

Store each ripple as an object with x, y and size in state, render one span per object, and remove it in the onAnimationEnd handler. The coordinate math matches the vanilla version.

Read the rectangle from event.currentTarget, not event.target, so a press on a child element still measures the button.

Does the ripple work on touch screens?

Yes, with one catch. Mobile browsers can draw their own tap highlight over the wave.

In WebKit and Blink browsers, set -webkit-tap-highlight-color to transparent on the element where the highlight shows. The Waves.js documentation flags the same problem for Internet Explorer on Windows Phone, and its remedy is the <meta name="msapplication-tap-highlight" content="no"/> tag.

Can I add a ripple to cards, links and menu items?

Yes, on any element that clips its children. Cards and menu items need position relative and overflow hidden.

Links are inline by default, so set display to inline-block or block first, because overflow does not clip an inline box.

What to Test Before a Ripple Goes Live

A CSS ripple effect is ready to release when keyboard users can see where focus is, reduced motion gets a fade, and the frame rate holds on a real phone. Check them in that order: the focus outline, then the reduced-motion fade, then rapid taps on a mid-range phone.

Keyboard comes first because a missing focus style blocks use, while a missing wave only costs polish. Reduced motion takes one media query, and touch behavior and paint cost need real hardware, so they come last.

Dropping the scale for reduced motion costs the wave its sense of direction, which is why the focus outline has to carry the confirmation.

The advice to use custom spans holds until Safari and Firefox both ship the CSS Painting API without a flag, and the position was verified in October 2026.

A web accessibility checklist covers the contrast and focus checks that remain.