iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🎇

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
https://sparkle-ripple.js.org/
npm
https://www.npmjs.com/package/sparkle-ripple
GitHub repository
https://github.com/yuyake-litrain/sparkle-ripple


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)
https://www.phonearena.com/news/Android-12-ripple-sparkle-effect-buttons_id132420
https://youtu.be/D2cU_itNDAI?t=950

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.


  1. User starts a tap/click (mousedown/touchstart event fires)

  2. A circle (hereafter referred to as "Ripple") appears
    The size doesn't necessarily start from 0

  3. The Ripple grows
    It expands from the coordinates of the tap to twice the distance (width) to the farthest corner of the tapped element.

  4. 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

  5. User ends the tap/click (mouseup/touchend event fires), or the user's finger leaves the element for some reason (touchcancel event fires), or the mouse leaves the element while still clicked (mouseleave event fires)

    1. If the Ripple has already finished expanding to its maximum
      Fade out the Ripple to remove it

    2. 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.

https://www.youtube.com/watch?v=vHprsEjFJp0

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:
https://www.easings.dev/create/cubic-bezier#x1=0&y1=0.49&x2=0&y2=1
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 \frac 1 6 of its maximum size.

https://youtu.be/DFyoa0IDD4g

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.

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/base/ripple.tsx#L79-L111

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.

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L121-L138

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.

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/base/ripple.tsx#L113-L120

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/base/ripple.tsx#L44-L77


Simple Sequence

  1. ripplePerform() is called by a click or tap.
  2. Since the array of RippleObj is held as state, setRipples() is called from ripplePerform() to add a ripple.
  3. 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 ripples state using setRipples(), 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 \sin\theta and \cos\theta in component form. I generated random values for \theta in the range of 0^\circ to 360^\circ to determine points on the circumference and then introduced some jitter using random numbers. Since simply adding a random number would only cause outward expansion, I both added and subtracted these offsets.

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/ripple/sparkles.ts#L34-L56

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.

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/ripple/utils.ts#L34-L36

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.

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L93-L100
https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/ripple/utils.ts#L42-L54

https://qiita.com/yukiB/items/cc533fbbf3bb8372a924

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.
https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/base/ripple.tsx#L113-L120

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:

  1. touchstart is called
  2. Wait for 0.1 seconds
    If touchmove is called during this 0.1s, set isScroll to true.
  3. Trigger the Ripple Effect only if isScroll === false
  4. Reset isScroll to false

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L169-L206
https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L208-L210

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.

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L195-L197

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

https://sparkle-ripple.js.org/

Installable from npm.

https://www.npmjs.com/package/sparkle-ripple

GitHub is here.

https://github.com/yuyake-litrain/sparkle-ripple

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