You know what everybody loves in their daily lives? A little validation, a little pat on the back, a little celebration — and a little confetti.
In this tutorial, you’ll learn how to implement a confetti cannon that can fire off of any element using React Spring from scratch. No previous React Spring experience required! The only prerequisite is a basic understanding of React and hooks.
If you want to jump ahead, you can skip to the completed CodeSandbox example.
Note: this tutorial uses styled-components
. If you’ve never used styled-components
before, don’t sweat it. It’s a powerful library for inline styling of React components, but it’s very readable, so you’ll get the gist of it just by looking at the code.
When I’m starting to implement something I’ve never seen before, I like to break it down into phases, starting with the core pieces, then polish afterward. We’ll attack this project step by step:
Let’s get started!
First, let’s make up a little app. We’ll make it a to-do app and set it to fire confetti from the checkbox when you complete an item.
Now, let’s add a single confetti dot, which we’ll play with for the next few steps of this tutorial.
const StyledConfettiDot = styled.svg` position: absolute; will-change: transform; `; const Dot = () => ( <StyledConfettiDot> <circle cx="5" cy="5" r="5" fill="blue" /> </StyledConfettiDot> );
React Spring is the animation library we’ll be using in this tutorial. It’s a unique library that takes the stance that animations powered by springs rather than keyframes look more natural. Instead of specifying how long an animation is and what changes occur at what time, you specify the tension, friction, and mass of the spring, as well as the start and end values of the animation, and let React Spring figure out how they relate to the spring.
Let’s get React Spring set up with our confetti dot. Run either of the following.
npm install react-spring
yarn add react-spring
Add the following import to ConfettiDot.js.
import { animated, config, useSpring } from 'react-spring';
animated
is used to wrap existing components to allow them to use react-spring
config
s are the preset spring configs that ship with react-spring
(we’ll be using the default
config)useSpring
is one of the main exports from react-spring
(there is a handful of other exports, but we’ll be focusing on useSpring
)ConfettiDot
enabled with react-spring
looks like this:
const AnimatedConfettiDot = animated(StyledConfettiDot); const Dot = () => { const { y } = useSpring({ config: config.default, from: { y: 0 }, to: { y: -50 } }); return ( <AnimatedConfettiDot style={{ transform: y.interpolate(yValue => `translate3d(0,${yValue}px,0)`) }} > <circle cx="5" cy="5" r="5" fill="blue" /> </AnimatedConfettiDot> ); };
We’ve used animated
to wrap our StyledConfettiDot
component. All we have to do is call animated(<component>)
.
useSpring
takes an object with various properties. First, a config
object — we’ll use the default
one shipped with react-spring
since it has no bounceback. Next, a from
object that states arbitrary initial values, followed by a to
object that states matching end values. The whole hook returns an object that matches the from
and to
objects. In this example, we’ve set a y
initial and end value, and we’re destructing the result to get the y
animated value.
Instead of using ConfettiDot
or StyledConfettiDot
in the render, we’re now using AnimatedConfettiDot
, the result of the animated
call.
In the style
attribute of AnimatedConfettiDot
, we use the result of the objects in useSpring
to turn the values into valid style values.
Let’s break down the style
attribute in more detail. Firstly, we’re using the style
attribute instead of props because when the values change, since it’s using animated
, it’ll just change the DOM element’s style values as opposed to causing a rerender in React. That means you can have complex animations fully on only one render. Without this, performance would be extremely slow.
Secondly, we’re using the interpolate
function on y
to convert it into a real string value. For values that are already equal to their final style value, such as a color or percentage value, you wouldn’t need to use interpolate
. We’ll demonstrate this later on.
While a circle moving upward is pretty fun, we want it to look like it’s firing out of a confetti cannon. To accomplish this, we’re going to make some pseudo-physics.
We’ll use react-spring
to simulate the confetti’s velocity at time t. Let’s make a spring that goes from 100 to 0.
const { upwards } = useSpring({ config: config.default, from: { upwards: 100 }, to: { upwards: 0 }, });
Let’s pretend this velocity represents pixels per second — so, starting at 100 pixels per second to 0 pixels per second.
To actually use this to move the confetti dot, we’ll do the following.
const initialY = 0; let totalUpwards = 0; const startTime = new Date().getTime() / 1000; let lastTime = startTime; return ( <AnimatedConfettiDot style={{ transform: upwards.interpolate(upwardsValue => { const currentTime = new Date().getTime() / 1000; const duration = currentTime - lastTime; const verticalTraveled = upwardsValue * duration; totalUpwards += verticalTraveled; lastTime = currentTime; return `translate3d(0, ${initialY - totalUpwards}px, 0)`; }) }} > <circle cx="5" cy="5" r="5" fill="blue" /> </AnimatedConfettiDot> );
This is a fun trick. Since interpolate
is called on every tick of react-spring
, we’re calculating the time between the current tick and the last tick, getting the current velocity, and calculating the distance traveled (velocity * duration since last tick), then adding that to the total distance traveled in totalUpwards
. Then we use totalUpwards
as the resulting translated value (using subtraction, since positive upward movement is negative y
axis movement in the DOM).
It’s looking great so far! We’ve successfully translated velocity into a translate
value. What’s still missing, though, is constant gravity. In terms of physics, that’s easy to implement, since gravity at time t
is just t * total time
.
const initialY = 0; let totalUpwards = 0; const startTime = new Date().getTime() / 1000; let lastTime = startTime; const gravityPerSecond = 30; return ( <AnimatedConfettiDot style={{ transform: upwards.interpolate(upwardsValue => { const currentTime = new Date().getTime() / 1000; const duration = currentTime - lastTime; const verticalTraveled = upwardsValue * duration; const totalDuration = currentTime - startTime; totalUpwards += verticalTraveled; lastTime = currentTime; const totalGravity = gravityPerSecond * totalDuration; const finalY = initialY - totalUpwards + totalGravity; return `translate3d(0, ${finalY}px, 0)`; }) }} > <circle cx="5" cy="5" r="5" fill="blue" /> </AnimatedConfettiDot> ); };
Changing the initial upward velocity to 300 results in the following.
Let’s add horizontal movement as well. It’s a similar mechanism, so I’ll cut to the chase.
const { horizontal, upwards } = useSpring({ config: config.default, from: { horizontal: 200, upwards: 300 }, to: { horizontal: 0, upwards: 0 } }); const initialX = 0; const initialY = 0; let totalUpwards = 0; let totalHorizontal = 0; const startTime = new Date().getTime() / 1000; let lastTime = startTime; const gravityPerSecond = 30; return ( <AnimatedConfettiDot style={{ transform: interpolate([upwards, horizontal], (v, h) => { const currentTime = new Date().getTime() / 1000; const duration = currentTime - lastTime; const totalDuration = currentTime - startTime; const verticalTraveled = v * duration; const horizontalTraveled = h * duration; totalUpwards += verticalTraveled; totalHorizontal += horizontalTraveled; lastTime = currentTime; const totalGravity = gravityPerSecond * totalDuration; const finalX = initialX + totalHorizontal; const finalY = initialY - totalUpwards + totalGravity; return `translate3d(${finalX}px, ${finalY}px, 0)`; }) }} > <circle cx="5" cy="5" r="5" fill="blue" /> </AnimatedConfettiDot> );
Similar to the upward velocity, we’ve added a horizontal velocity spring in existing from
and to
values and calculated the horizontal distance traveled for each tick of the spring.
The one new thing is that we’re not just interpolating one value anymore, so we need to use the interpolate
function exported from react-spring
. This function’s first argument is an array of springs, and the second argument is a function that does something with each of the spring values in that array. So in this particular example, the first argument is a list of the upward and horizontal velocity, and the second argument is a function that has upward velocity as its first argument and horizontal velocity as its second argument.
Before we start making many pieces of confetti go flying, let’s make this single piece actually look like it’s coming out of a specific element.
The first step is to make the confetti appear when the checkbox is clicked.
const ToDo = ({ text }) => { const [done, setDone] = useState(false); return ( <StyledToDo> <input type="checkbox" onChange={() => setDone(!done)} /> <span> {text} {done ? ":ok_hand:" : ""} </span> {done && <ConfettiDot />} </StyledToDo> ); };
In each ToDo
component, when the done
state is true, render a ConfettiDot
.
It looks like it’s aligned with the checkbox, but if you look closely, you might notice that the animation starts at the top-left of the checkbox. It looks okay, but if it were a different element, such as a text box input, this would look pretty strange.
We’ll use ref
s to align the animation with the checkbox.
const alignWithAnchor = anchorRef => { if (anchorRef.current == null) { return { initialX: 0, initialY: 0 }; } const { height, width } = anchorRef.current.getBoundingClientRect(); return { initialX: width / 2, initialY: height / 2 }; }; const Dot = ({ anchorRef }) => { const { initialX, initialY } = alignWithAnchor(anchorRef); // ... } const ToDo = ({ text }) => { const confettiAnchorRef = useRef(); const [done, setDone] = useState(false); return ( <StyledToDo> <input ref={confettiAnchorRef} type="checkbox" onChange={() => setDone(!done)} /> <span> {text} {done ? ":ok_hand:" : ""} </span> {done && <ConfettiDot anchorRef={confettiAnchorRef} />} </StyledToDo> ); };
To use the ref
, follow these steps:
ToDo
, call useRef()
ref
to the input
by using ref={confettiAnchorRef}
(now the ref will contain the DOM element of the input
)ConfettiDot
ConfettiDot
, access the ref
and pass it to a helperref
elementNow the animation is a little cleaned up.
Now that we’ve got a single confetti dot moving the way we want it to when we want it to, let’s make it a confetti cannon that sprays a randomized fan of confetti. We want our confetti cannon component to:
ref
prop for alignmentconst ToDo = ({ text }) => { const confettiAnchorRef = useRef(); const [done, setDone] = useState(false); return ( // ... {done && } ); };const ConfettiCannon = ({ anchorRef, dotCount }) => ( <> {new Array(dotCount).fill().map((_, index) => ())} </> );
It doesn’t look too different, does it? Even though we’re rendering five confetti dots, they all have identical animations, since the confetti dots have their upward and horizontal movement props baked in. Let’s extract those and randomize them within a range.
const randomInRange = (min, max) => { return Math.random() * (max - min) + min; }; const ConfettiCannon = ({ anchorRef, dotCount }) => ( <> {new Array(dotCount).fill().map((_, index) => ( <ConfettiDot key={index} anchorRef={anchorRef} initialHorizontal={randomInRange(-250, 250)} initialUpwards={randomInRange(200, 700)} /> ))} </> ); const Dot = ({ anchorRef, initialHorizontal, initialUpwards }) => { const { initialX, initialY } = alignWithAnchor(anchorRef); const { horizontal, upwards } = useSpring({ config: config.default, from: { horizontal: initialHorizontal, upwards: initialUpwards }, to: { horizontal: 0, upwards: 0 } }); // ... }
Now instead of having a baked-in initial horizontal and upward velocity, we’ll randomize each dot. Horizontal velocity goes from -250 to 250 to represent dots flying both left of the anchor and right of the anchor, and the upward velocity goes from 200 to 700. Feel free to play around with these values.
At this point, we’ve done all the hard work required for this project. To polish it off, we’ll do the following.
Let’s break this down step by step.
The confetti should disappear as it nears the end of its animation. To accomplish this, all we need to do is add the following in ConfettiDot
.
const Dot = ({ anchorRef, initialHorizontal, initialUpwards }) => { const { initialX, initialY } = alignWithAnchor(anchorRef); const { horizontal, opacity, upwards } = useSpring({ config: config.default, from: { horizontal: initialHorizontal, opacity: 80, upwards: initialUpwards }, to: { horizontal: 0, opacity: 0, upwards: 0 } }); // ... return ( <AnimatedConfettiDot style={{ opacity, transform: interpolate([upwards, horizontal], (v, h) => { // ... }) }} > <circle cx="5" cy="5" r="5" fill="blue" /> </AnimatedConfettiDot> ); }
Since opacity actually returns a number, and that’s what the valid style
value is, we don’t need to interpolate it. We can drop it right into the style
attribute of AnimatedConfettiDot
.
Blue is fine, but of course, more variance is better. Let’s add a color
prop to ConfettiDot
, add a colors
prop to ConfettiCannon
, and randomly pick colors from there to assign to created ConfettiDot
s.
const Dot = ({ anchorRef, color, initialHorizontal, initialUpwards }) => { // ... return ( <AnimatedConfettiDot // ... > <circle cx="5" cy="5" r="5" fill={color} /> </AnimatedConfettiDot> ); } const randomInRange = (min, max) => { return Math.random() * (max - min) + min; }; const randomIntInRange = (min, max) => Math.floor(randomInRange(min, max)); const ConfettiCannon = ({ anchorRef, colors, dotCount }) => ( <> {new Array(dotCount).fill().map((_, index) => ( <ConfettiDot key={index} anchorRef={anchorRef} color={colors[randomIntInRange(0, colors.length)]} initialHorizontal={randomInRange(-250, 250)} initialUpwards={randomInRange(200, 700)} /> ))} </> );
This can be especially useful if you want to stylize your confetti in the brand colors of the app using this library.
Circles are also fine, but they don’t look like the most convincing confetti pieces in the world. Let’s randomly make squares and triangles as well.
const Circle = ({ color, size }) => ( <circle cx={`${size / 2}`} cy={`${size / 2}`} r={`${(size / 2) * 0.6}`} fill={color} /> ); const Triangle = ({ color, size }) => { const flipped = flipCoin(); return ( <polygon points={`${size / 2},0 ${size},${randomInRange( flipped ? size / 2 : 0, size )} 0,${randomInRange(flipped ? 0 : size / 2, size)}`} fill={color} /> ); }; const Square = ({ color, size }) => { const flipped = flipCoin(); return ( <rect height={`${randomInRange(0, flipped ? size : size / 2)}`} width={`${randomInRange(0, flipped ? size / 2 : size)}`} fill={color} /> ); }; const getRandomShape = color => { const Shape = randomFromArray([Circle, Square, Triangle]); return <Shape color={color} size={10} />; }; return ( <AnimatedConfettiDot // ... > {getRandomShape(color)} </AnimatedConfettiDot> );
Now we’ll randomly get a triangle, square, or circle. The triangle and square have some extra code in them to make sure you never end up with a square that’s just a line or a triangle that’s just a line. I’ve left out the code for flipCoin
and randomFromArray
from this snippet, but it’s in the CodeSandbox.
One last thing that’d be nice to polish: as of now, there’s no rotation, which makes it so that each triangle has a point facing directly up, and each rectangle is either fully vertical or fully horizontal. Let’s fix that.
const ConfettiCannon = ({ anchorRef, colors, dotCount }) => ( <> {new Array(dotCount).fill().map((_, index) => ( <ConfettiDot key={index} anchorRef={anchorRef} color={colors[randomIntInRange(0, colors.length)]} initialHorizontal={randomInRange(-250, 250)} initialUpwards={randomInRange(200, 700)} rotate={randomInRange(0, 360)} /> ))} </> ); const Dot = ({ anchorRef, color, initialHorizontal, initialUpwards, rotate }) => { // ... return ( <AnimatedConfettiDot style={{ opacity, transform: interpolate([upwards, horizontal], (v, h) => { // ... return `translate3d(${finalX}px, ${finalY}px, 0) rotate(${rotate}deg)`; }) }} > {getRandomShape(color)} </AnimatedConfettiDot> ); };
The last aspect to randomize is the size of each dot. Currently, all the dots are the same size, and it’s especially obvious with the circles. Let’s use a similar approach as we did for rotation.
const getRandomShape = (color, size) => { const Shape = randomFromArray([Circle, Square, Triangle]); return <Shape color={color} size={size} />; }; const Dot = ({ anchorRef, color, initialHorizontal, initialUpwards, rotate, size }) => { // ... return ( <AnimatedConfettiDot // ... > {getRandomShape(color, size)} </AnimatedConfettiDot> ); }; const ConfettiCannon = ({ anchorRef, colors, dotCount }) => ( <> {new Array(dotCount).fill().map((_, index) => ( <ConfettiDot key={index} anchorRef={anchorRef} color={colors[randomIntInRange(0, colors.length)]} initialHorizontal={randomInRange(-250, 250)} initialUpwards={randomInRange(200, 700)} rotate={randomInRange(0, 360)} size={randomInRange(8, 12)} /> ))} </> );
Congratulations! You’ve made confetti from scratch using React and React Spring. Now you should be much more familiar with using React Spring’s useSpring
hook to create powerful and performant animations.
I’ll leave you with these branded confetti cannons!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]