Skip to content

COMPONENT

Ripple

Press feedback that finds its own colour, and that plays when you let go rather than while you hold. One class on a surface that is already interactive, no layout change, and nothing to configure to get the colour right.

Tap one β€” and tap it fast

HTML
<button type="button" class="btn ripple" x-ds-ripple>SECONDARY</button>
<button type="button" class="btn btn-primary ripple" x-ds-ripple>PRIMARY</button>
<button type="button" class="btn btn-quiet ripple" x-ds-ripple>QUIET</button>
<button type="button" class="btn btn-on-cream ripple" x-ds-ripple>ON CREAM</button>

THE RIPPLE IS THE RELEASE, NOT THE PRESS

Here is the problem with :active. It lasts exactly as long as your finger is down, and a real click is somewhere between 60 and 90 milliseconds. The ripple takes 480. So an effect driven by the press is one you see a fifth of before it vanishes β€” and the faster you click, the less of it there is. Tap and release, and most of the time you saw nothing at all.

So the press does not animate anything. It puts a mark under your pointer and leaves it sitting there. Letting go is what grows it and fades it out, over the full duration, every time. Tap as fast as you can above; you still get the whole thing, because by then the pressed rule has stopped applying and the resting rule β€” which is the animated one β€” has taken over.

SO WHY NOT GROW UNDER YOUR FINGER AS WELL?

Fair question, and it was built before it was refused. Doing both means an entry animation and an exit animation on one element: display: none at rest, @starting-style for the way in, and transition: display … allow-discrete for the way out β€” the same shape modal uses. It is lovely in Chromium 151. Firefox 153 does not run the exit at all: measured on a bare element with a class toggle, the box goes display: none on the same frame and nothing plays. That is the original defect back again, for about a reader in four, so it is out.

Handing the press to a @keyframes and the release to a transition was tried too, and it is worse: neither engine carries opacity across that handoff. Both snap it to zero the instant the animation is removed, so the circle finishes its travel invisibly.

THE COLOUR IS currentColor, AND THAT IS THE WHOLE DESIGN

A design system cannot ask every caller to say "dark ripple here, light ripple there" β€” that is a decision somebody gets wrong on the one button nobody tested, and it stays invisible until it is looked at. A legible surface always has contrasting ink, so the ink is always the right ripple. The four buttons above prove it: the dark-on-cream one ripples dark, every other one ripples light, and none of them was told which.

The blade

.ripple-shine swaps the circle for a band of light crossing the surface. It is the same pseudo-element and the same ink β€” only the shape and the path change. A highlight travels rather than spreads, so it ignores where the press landed and always goes the same way.

It is released rather than pressed, for the same reason the circle is. Holding one gathers the light against the leading edge; letting go sweeps it across. Hold one down and you can watch it wait.

Tap one, then hold one

HTML
<button type="button" class="btn btn-primary ripple ripple-shine">PRIMARY</button>
<button type="button" class="btn btn-on-cream ripple ripple-shine">ON CREAM</button>
<button type="button" class="btn ripple ripple-shine">SECONDARY</button>

Reference

Read from components/ripple.css when this page was built, so it cannot fall behind the stylesheet.

CLASSES.ripple.ripple-shine
SETTINGSSet the first on one element; a theme sets the second for all of them.--ripple-strength β€” falls back to --ds-ripple-strength--ripple-reach β€” falls back to --ds-ripple-reach--ripple-duration β€” falls back to --ds-shine-duration--ripple-seed β€” falls back to --ds-ripple-seed
READS--ds-ripple-duration--ds-ripple-ease--ds-ripple-fade--ds-shine-armed--ds-shine-ease--ds-shine-edge--ds-shine-tilt

Notes

Tuning it

Two levels, and it is the pattern flow uses for --flow and frame for --ratio: the system default is a --ds-* token a theme owns, and a single instance overrides it with the unprefixed property.

--ripple-inkThe colour. Defaults to currentColor, and the only reason to set it is a surface whose ink is genuinely the wrong ripple β€” a photograph, say.
--ripple-strengthPeak opacity of the ink.
--ripple-durationHow long the effect takes to play out once you let go.
--ripple-seedHow big the mark under the pointer is while a press is held, as a multiplier on the same diameter --ripple-reach uses. At zero, leaning on a control would show nothing at all.
--ripple-reachHow far past the box the circle grows, as a multiplier. Raise it for a surface much wider than it is tall.
--ripple-x Β· --ripple-y Β· --ripple-sizeThe origin and the diameter. Both default to the middle and to the host's width β€” x-ds-ripple writes them from the actual press.
/* One instance, slower and stronger than the system default. */
.hg-hero-cta {
  --ripple-duration: 700ms;
  --ripple-strength: 0.34;
}

/* A surface whose ink is the wrong ripple β€” the escape hatch. */
.hg-photo-tile {
  --ripple-ink: white;
}

REDUCED MOTION GETS A TINT, NOT NOTHING

Everything that moves sits inside prefers-reduced-motion: no-preference. Outside it a press still tints the surface, so a reader who asked for less motion is acknowledged rather than ignored. That is why the base rule describes a rectangle covering the host: the circle is the enhancement, not the floor.

The release cannot linger there, and that is deliberate rather than missed. base.css collapses every transition in the system when that preference is set, and this file must not spell the same decision a second time. So the tint arrives on the press and clears on the release, both instantly. That is the floor.

WHAT THE KEYBOARD GETS, MEASURED

:active is not the keyboard, and this page would rather say so than imply otherwise. Space on a <button> does set it, so those ripple. Enter on a <button> does not, and neither does Enter on an <a class="btn">. The focus ring is the keyboard's affordance and it is untouched β€” the host's own overflow: hidden clips its descendants, never its own ring.

WHEN NOT TO WEAR IT

The class sets overflow: hidden on its host, which is what clips the effect to the host's own corners without restating a radius. A surface with something deliberately hanging outside it β€” a badge on a corner, a popover anchored inside β€” should not wear this class; put it on the thing being pressed instead.