iTranslated by AI
Building a React Library to Bring Android 12-Style Sparkle Ripple Effects to the Web
Summary
I created a React library that reproduces the sparkling ripple effect introduced in Android 12 and later on the Web.
Documentation npm GitHub repository
Try it out
I've uploaded a demo to CodePen, so you can try it out here.
How to use
The CodePen code is a good reference.
Or, jump straight to this section to check it out.
Please also take a look at the documentation site.
Reproducing something from 3 years ago on the Web while Liquid Glass is all the rage.
Nice to meet you, I'm Litrain.
Out of the blue, do you like Material Design?
While Apple has announced the new Liquid Glass, making it arguably the most talked-about design system right now, I was busy obsessing over reproducing the sparkly Ripple Effect introduced in Android 12 on the Web.
Android 12 with the sparkling effect that Google introduced along with the new Material You design system —
(Details can be found in the following article and video from 15:50 onwards)
As I touched the actual OS while being confused by the major design changes in Material You, I came to really like this Ripple Effect. It's stylish, fun, and I think it's something that adds "texture to the UI" that Android didn't have before. Texture really is important.
On the other hand, it seems to have been unpopular on the internet (especially overseas).
There were many harsh opinions, such as it looking like a graphics bug, being unnecessary, or just plain weird. After several Android version updates, this sparkle has now become quite subdued.
Nonetheless, this sparkle still exists in a subtle way, and most importantly, because I like it, I decided to work on "reproducing this on the Web!"
Why on the Web?
A big reason was being impressed by the reproduction of the Ripple Effect in Vuetify, a Material Design library for Vue.
It was beautifully reproduced, making it incredibly fun and pleasant to interact with. I thought, "If only I could use this Ripple Effect, which goes so well with Material 3, on the Web as well...!"
Observing the Ripple Effect
Now, putting the sparkle aside, if you were to implement the plain Ripple Effect that has existed since the Material 2 era, what would you do first?
...Since I love building things with great attention to detail, I'll start by observing and thinking about why it feels so good to operate and what exactly the Ripple Effect is.
Thinking about the Ripple Effect
🙃 "Isn't a ripple effect just a circle expanding from where you tap to the edges and then disappearing?"
No, that's not it (flatly).
When you look closely, you'll realize that the Ripple Effect is deeper than you might think.
Let's organize what the ripple effect is like here.
-
User starts a tap/click (
mousedown/touchstartevent fires) -
A circle (hereafter referred to as "Ripple") appears
The size doesn't necessarily start from 0 -
The Ripple grows
It expands from the coordinates of the tap to twice the distance (width) to the farthest corner of the tapped element. -
Even if the Ripple Effect has expanded to its maximum, the user continues to tap or click
The Ripple is not removed, but remains at its maximum expanded size -
User ends the tap/click (
mouseup/touchendevent fires), or the user's finger leaves the element for some reason (touchcancelevent fires), or the mouse leaves the element while still clicked (mouseleaveevent fires)-
If the Ripple has already finished expanding to its maximum
Fade out the Ripple to remove it -
If the Ripple hasn't reached its maximum size yet
Wait for the Ripple to finish expanding to its maximum before fading it out and removing it.
-
This is the Ripple Effect. I believe it's incomplete if any of these are missing.
Actually, there might still be something missing.
I think you'll understand better if you try it, so please give it a go.
Key points other than the processing sequence
The characteristics of the Ripple Effect don't stop there.
Since it's an effect involving animation, easing and duration are crucial.
Easing
Easing is a way to give variation to the pace of an animation's progress.
By adding pace to an animation, you can make it feel lively and closer to real-world dynamics.
While it's important to note that not all animations need easing and sometimes they're better without it, the ripple effect does need easing. It feels much better with it.
So, we add easing, but the ripple effect is meant to inform the user that their input has been received.
Therefore, the animation of the circle's expansion/contraction feels better if it accelerates sharply immediately after tapping. After that, we set an easing that feels like negative acceleration is being applied.
cubic-bezier(0, 0.49, 0, 1)
You can check the graph here: In addition, we add easing to fade-outs and fade-ins as appropriate.
Duration
What kind of animation do you think is best when an element that didn't exist before makes its appearance with animation?
🤔 "Since it didn't exist, maybe scaling up from size 0 or moving from off-screen
x: (negative number)?"
True!
But actually, that's not necessarily the case.
This is something often considered in video production: the human brain interpolates images.
In other words, it fills in the missing parts for us. So, in fact, it doesn't have to be off-screen or start from size 0.
*Note: Be careful, because the brain won't interpolate if you use a slow, unenergetic appearance animation!
🤨 "...But why do that?"
For example, let's say we have a Ripple that appears at size 0 and scales to 200, and another that appears at size 70 and scales to 200. Since the brain interpolates, both can be viewed without feeling unnatural.
However, the latter feels more responsive. Since it's guaranteed to appear at size 70 immediately after clicking, it feels less sluggish, making for a more pleasant Ripple Effect.
So this time, I decided to start the Ripple animation from
Implementing in React
Now that I have a deeper understanding of the Ripple Effect, I immediately set out to implement it in React.
For the scaling of the ripple, I decided to use the Web Animation API.
The reason is that it can be used without relying on external libraries, is easy to understand, and allows waiting for the animation to finish using the finished property.
As mentioned in Thinking about the Ripple Effect, this property is important because there are situations where we need to check if the animation has completed.
A "React-ish" Implementation
Although I'm not an expert on React, I had the impression that React builds UIs based on a given state. Therefore, I prepared a <RippleContainer /> as the component to which the Ripple Effect would be applied and decided to store the currently displayed ripples in the state.
By doing this, ripples can be manipulated by adding or removing RippleObj objects to the ripples array held in the State.
As you can see from the animation code above, this is why I trigger the animation the moment the component is displayed using useEffect() in the <Ripple /> component.
Since a fade-out animation is required before removing a ripple, I prepared a property called tobeDeleted. When tobeDeleted becomes true, the <Ripple /> component performs the fade-out animation. Once complete, it calls a function from the parent element (<RippleContainer />) to remove the RippleObj from the ripples state in the parent.
Simple Sequence
-
ripplePerform()is called by a click or tap. - Since the array of
RippleObjis held as state,setRipples()is called fromripplePerform()to add a ripple. - If the ripple animation has finished and the click or tap is no longer being held, the child element performs a fade-out animation. After completion, the ripple is removed from the
ripplesstate usingsetRipples(), and the ripple disappears from the screen.
Sparkle Processing and Ripple Size Calculation
Sparkles (hereafter referred to as "Sparkles") are the core of this project, yet they haven't made an appearance until now. You can find the detailed implementation in sparkles.ts, but here are a few key points.
Drawing Sparkles on Canvas
Sparkles are drawn on a single Canvas via the <SparkleCanvas /> component, which is located inside each <Ripple /> component.
This approach was reached through trial and error. I initially tried placing a vast number of <div> tags and even tried regenerating the Canvas every frame (due to my lack of experience with Canvas animations). The former was too laggy on Firefox, and while the latter was much better, I felt there was still room for improvement. Ultimately, I settled on using requestAnimationFrame() to handle animations within a single Canvas.
Calculation Logic
I implemented the logic such that points are scattered in the shape of a circle, with a radius corresponding to the Ripple's radius at the moment of drawing.
A circle can be represented by
Ripple-Related Calculations
Calculating Maximum Width Using the Pythagorean Theorem


As detailed in ripple.ts, when calculating the ripple's size, you must set the maximum width to twice the distance from the click point to the farthest corner (since width is the diameter). This ensures the edge of the ripple is never visible when it reaches its maximum size.
This is where the Pythagorean theorem comes into play.
Making Offset Always Relative to <RippleContainer />
I also wanted to use the offset to animate the ripple starting from the clicked coordinates. However, even with an event handler registered on <RippleContainer />, if a child element is clicked, event.offsetX and event.offsetY will be relative to that child, causing the ripple to be misaligned.
To ensure the coordinates are always relative to the parent (<RippleContainer />), I obtain the parent's position using getBoundingClientRect() and subtract it from event.clientX and event.clientY.
Challenges I Encountered
useEffect() running twice, making ripple removal behavior strange
Since I was managing Ripples as state in <RippleContainer />, re-renders occurring during the addition or deletion of Ripples caused a certain function (deleteRipple()) to be redefined repeatedly. This led to the useEffect() in <Ripple />, which was passed that function, being called multiple times, causing strange behavior.
I resolved this by using useCallback to prevent the function from being redefined during re-renders. However, since the documentation states that useCallback should only be used for performance optimization, I also implemented a fix using Ref to ensure the logic runs only once even if useEffect() is called multiple times.
Specifically, when tobeDeleted (which specifies whether to perform the deletion process) was true, multiple re-renders would trigger useEffect() repeatedly, causing the ripple deletion process to occur multiple times and resulting in unintended behavior. Therefore, I decided to manage it with Ref to ensure the deletion process executes exactly once.
Ripple triggering unintentionally with a light touch during scrolling on touch devices
Simply implementing a logic where touchstart shows the Ripple and touchend removes it caused the Ripple Effect to trigger even with a momentary touch during scrolling.
This meant the effect was firing even when the user didn't intend to tap, which can be alarming for users.
"Did I just accidentally press a dangerous button without realizing it...?!"
I addressed this with the following steps:
touchstartis called-
Wait for 0.1 seconds
Iftouchmoveis called during this 0.1s, setisScrolltotrue. - Trigger the Ripple Effect only if
isScroll === false - Reset
isScrolltofalse
Double ripple triggering on touch devices
On touch devices, events are called in the order of touchstart -> touchend -> mousedown -> mouseup, meaning both touch-related and mouse-related events are triggered.
Since the Ripple Effect starts on both touchstart and mousedown, ripples were appearing twice on touch devices.
I handled this by disabling any Ripple Effect triggers from mousedown for one second after touchstart is called.
How was it?
Do you now have a general sense of the depth of the Ripple Effect and why I felt it was worth turning into a library and distributing it...?
Honestly, implementing this yourself is just plain painful, especially if the Ripple Effect isn't the main focus of your project. So, I thought, "Since I've already implemented it, why not make it a library?" and that's how it came to be.
A site I worked incredibly hard on
Installable from npm.
GitHub is here.
Usage
Import it and wrap the element you want to apply the Ripple to with <RippleContainer />.
import { RippleContainer } from 'sparkle-ripple'; // Import!
import styles from './some_css_file.module.css'; // Apply CSS
import "sparkle-ripple/css"; // sparkle-ripple CSS
const YourComponent = () => {
return (
<RippleContainer
isMaterial3 = {true}
beforeRippleFn = {(event) =>{}}
className = {styles.rippleContainer}
rippleColor = "hsla(29,81%,84%,0.15)"
sparklesColorRGB = "255 255 255"
opacity_level1 = "0.4"
opacity_level2 = "0.1"
sparklesMaxCount = 2048
>
<div className={styles.children} />
</RippleContainer>
);
};
export default YourComponent;
Property Descriptions
| Property Name | Optional | Description | Default Value | Accepted Type |
|---|---|---|---|---|
as |
○ | What element the RippleContainer is rendered as |
"div" |
ElementType |
isMaterial3 |
○ | Whether to use the Material 3 (sparkling) ripple effect | true |
boolean |
beforeRippleFn |
○ | Function executed immediately before the Ripple Effect appears (useful for adding shadows, etc.) | ()=>{} |
(event: React.MouseEvent | React.TouchEvent) => void |
className |
○ | The className for the element rendered by RippleContainer via as
|
undefined |
string |
children |
○ | Child elements | undefined |
ReactNode |
rippleColor |
○ | Color of the ripple. If opacity isn't specified, they won't be visible when overlapping, so please specify it if possible. | "#ffffff35" |
string |
sparklesColorRGB |
○ | The color of the Sparkles as space-separated RGB values. Opacity cannot be used. | "255 255 255" |
string |
opacity_level1 |
○ | The first level of opacity just before the Sparkles disappear | "0.2" |
string |
opacity_level2 |
○ | The second level of opacity just before the Sparkles disappear | "0.1" |
string |
sparklesMaxCount |
○ | Total amount of Sparkles | 2048 |
number |
| Other properties | ○ | Standard properties of the element specified in as. For example, the href property when as is set to "a". |
- | - |
Example
A link with a Ripple.
import { RippleContainer } from 'sparkle-ripple';
import "sparkle-ripple/css"; // m3ripple CSS
const RippleLink = ({
href,
children,
}) => {
return (
<a
className='link'
href={href}
>
<RippleContainer
isMaterial3={true}
className='rippleContainer'
rippleColor="hsla(29,97%,75%,0.15)"
sparklesColorRGB="255 255 255"
opacity_level1="0.4"
opacity_level2="0.1"
>
<div className='desc'>{children}</div>
</RippleContainer>
</a>
);
};
.rippleContainer {
width: fit-content;
color: #f7ca9a;
background: #5c4b39;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
padding: 12px 24px;
}
a {
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}
Bugs in Current Version Fixed in v2.0.4
Fixed in v2.0.4, so it's not necessary. For reference.
The CSS applied by the library may not function correctly sometimes (especially in SPAs).
As a workaround, apply this CSS to the RippleContainer.
If your environment doesn't support :global(), please remove :global() and just use .ripple, etc.
.RippleContainer {
/* Workaround(m3ripple's css modules is not working) */
overflow: hidden;
position: relative;
z-index: 0;
& :global(.ripple) {
position: absolute;
border-radius: 100%;
z-index: -1;
transform: translateX(-50%) translateY(-50%);
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
& :global(.sparkles_canvas) {
position: absolute;
user-select: none;
}
}
Conclusion
Wait, isn't "How was it?" usually the signal for the end? Setting that aside, how was it?
I put a lot of effort into making this, which is why the article ended up being so long. I hope my enthusiasm came across.
This was my first time creating a React library and publishing it to npm. I decided to take the opportunity to try various things, such as getting started with GitHub Actions and adding Provenance, which was quite enjoyable.
If you have any questions, please feel free to leave a comment.
Pull requests and issues are also very welcome!
Discussion