Custom Ripple Exercise
I'm not a huge fan of Material Design, but I recently noticed that I quite enjoy the little ripple effect you get on
their buttons.
You can see them in the Angular Material documentation
when clicking on the cards.
Since I thought it would be a fun little challenge, I tried to replicate it with a bunch of more or less modern CSS (and a bit of JS).
The resulting code is provided with the codepen link above.
Setup
I started out with a simple HTML button and some basic styling. The goal is not to touch this code at all.
<button class="ripple">Button</button>
<button class="primary ripple">Primary</button>
<button class="black ripple">Dark</button>Layering approaches
From my understanding, there are two main ways to add in the ripple circles, you can either
- use a
::beforeor::afterpseudo-element or - use a layered background on the element
Both have their ups and their downs:
Pseudo-elements are a kind of limited resource, since there is only a maximum of one ::before and ::after for every
element on the page. A lot of CSS hacks tend to use them, so it is basically unavoidable
that you'll get conflicts at some points.
Layered backgrounds, however, require that the background on the affected elements has to be set up in a certain way, so
that the ripple can "inject" into it.
While this may be a good option if you're only targeting a limited set of components, it's pretty much impossible to
just slap on a simple class with this approach.
I chose to use pseudo-elements for my exercise.
Foundations
First off, let's create an ::after pseudo-element (since that naturally layers on top of the element) and make it
overlay the targeted element:
.ripple {
position: relative;
&::after {
content: "";
position: absolute;
inset: 0;
opacity: 0;
border-radius: inherit;
background-color: rgba(255 0 0 / 0.6);
pointer-events: none;
}
&:active::after {
opacity: 0.3;
}
}The buttons now get a red overlay when clicked
Since the ::after pseudo-elements are on top of the buttons, it is important to set pointer-events: none,
so that clicking the buttons still goes through to the actual button elements underneath.
Of course, the ripple effect should be circular. I decided to use a radial-gradient for this instead of a border-radius
since this offers some nice options for further customizing this later.
.ripple {
/* ... */
&::after {
background: radial-gradient(circle farthest-corner at center, currentColor 30%, transparent 100%);
}
}To have some automatically adapting contrast, one can use currentColor to use the text color inside this background.
Animating
The circle shouldn't just statically rest where it is, but should grow out when clicked and slowly fade out when let go.
To transition the gradient, we can use a custom property. For transitioning them, it is required to declare them with
an @property at-rule.
@property --ripple-size {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.ripple {
/* ... */
&::after {
/* ... */
background: radial-gradient(
circle farthest-corner at center,
currentColor,
currentColor var(--ripple-size),
transparent var(--ripple-size),
transparent 100%
);
opacity: 0;
transition: opacity 0.5s ease-out, --ripple-size 0.5s ease-out;
}
&:active::after {
opacity: 0.15;
--ripple-size: 100%;
}
}Well, this is the point where the pure-CSS solution ends, and I had to get a little help from JavaScript.
Currently, when you release the button, the ripple declines again before vanishing.
This is not what Material does and not what I want though.
I'd like it to continue expanding while slowly fading out.
I played around a bit with CSS animations and combinations with transitions, but nothing really worked like I wanted to, especially since animations and transitions don't pair nicely. Transitions strictly transition between actually declared values and will just ignore that a property was in the middle of an animation:
@property --ripple-size {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
button {
/* ... */
&::after {
/* ... */
--ripple-size: 100%;
transition: opacity 3s ease-out, --ripple-size-100 3s ease-out;
}
&:active::after {
opacity: 0.5;
animation: 3s ease-out forwards --ripple-expand;
}
}
@keyframes --ripple-expand {
from { --ripple-size: 0% }
to { --ripple-size: 100% }
}An attempt with a combination of transition and animation. I increased the opacity and transition/animation duration so the snapping when releasing early is more noticeable.
So, JavaScript to the rescue.
With a little event handler that adds and removes a class that resets the sizes, this looks pretty nice already.
.ripple {
/* ... */
&::after {
/* ... */
--ripple-size: 100%;
opacity: 0;
}
&:active::after {
opacity: 0.15;
--ripple-size: 100%;
}
&.ripple-start::after {
--ripple-size: 0%;
}
}document.addEventListener('pointerdown', (event) => {
if (event.target.classList.contains("ripple")) {
event.target.classList.add("ripple-start");
setTimeout(() => event.target.classList.remove("ripple-start"), 10);
}
})Even on quick releases, the ripple now expands outwards.
Positioning the ripple
We're still missing one of the arguably nicest visual cues. The ripple should expand from where the user has clicked.
For this a little JavaScript is needed anyway, so I'm not feeling too bad for the little .ripple-start hack above.
All that is needed is a few more custom properties as a way for JavaScript to pass the click position to CSS.
.ripple {
/* ... */
&::after {
/* ... */
background: radial-gradient(
circle farthest-corner at var(--ripple-x, 0) var(--ripple-y, 0),
currentColor,
currentColor var(--ripple-size),
transparent var(--ripple-size),
transparent 100%
);
}
/* ... */
}document.addEventListener('pointerdown', (event) => {
if (event.target.classList.contains("ripple")) {
event.target.style.setProperty("--ripple-x", `${event.offsetX}px`);
event.target.style.setProperty("--ripple-y", `${event.offsetY}px`);
event.target.classList.add("ripple-start");
setTimeout(() => event.target.classList.remove("ripple-start"), 10);
}
})Final Result
The codepen includes some further minor adjustments such as a bouncy easing for the opacity (this is a nice tool for creating easings) and a subtle gradient inside the ripple.
I think the result is actually far better than the Material implementation, especially on large surfaces.
Some extra-large buttons as a treat :)
PS
Hi, and thanks for reading.
This is the first blog post I've ever written, so this was also an exercise in writing and how to embed the buttons and code blocks etc.
I hope you liked it :)