Hey, all!
Today, I wanted to make a magic 8-ball. We'll be using react
, framer-motion
, and styled-components
.
The goal is to make something this:
If you've already got a site up and running that can use React, just add framer-motion
and styled-components
:
// if you're using yarnyarn add framer-motion styled-components// if you're using npmnpm install framer-motion styled-components
If you don't have a site that's running React, I'd recommend just starting one with create-react-app
:
// initialize a project with create-react-appnpx create-react-app eight-ball// move into the directorycd eight-ball// install dependenciesyarn add framer-motion styled-components
If you're going the create-react-app
route, you can delete everything in the App.js
file, and we'll build it up together.
Now that we're all set up, let's get to building. We'll make a few styled components along the way:
// App.jsimport React from 'react'import styled from 'styled-components'const Container = styled.div``const Ball = styled.div``const Window = styled.div``const Dice = styled.div``const Message = styled.div``const Button = styled.button``export default () => {return (<Container><Ball><Window><Dice><Message>You can bet on it.</Message></Dice></Window></Ball><Button>pls tell me</Button></Container>);}
If you're not familiar with the styled.div
, it's using styled-components
, which is a CSS-in-JS library that I'm a big fan of. Using this library means that we don't have to worry about creating and loading CSS files or about styles interfering with each other. The library will make unique classes that combine all of the CSS rules. And we'll see later on, we can use passed props to conditionally style the components, instead of conditionally adding or removing classes from an element to change their styles.
Let's see where the above gets us:
Next up, we should add some reponses for the 8-ball and make that button pick a new response for us. We'll import the useState
react hook to keep track of the current response index, using that index to populate the message instead of a hard-coded string.
// App.jsimport React from 'react'import React, { useState } from 'react' //* add useStateimport styled from 'styled-components'const Container = styled.div``const Ball = styled.div``const Window = styled.div``const Dice = styled.div``const Message = styled.div``const Button = styled.button``//* Add our list of possible responsesconst RESPONSES = ["You can bet on it","You? Seriously? No. Just, no","Come on, you're embarrassing yourself","Actually, yeah","YAS KWEEN","Ehhhhhh don't plan on it","Only if you become a completely different person",]//* get an index of 0 - RESPONSES.lengthconst getRandomIndex = () => Math.floor(Math.random() * responses.length)export default () => {//* Add state trackerconst [currentIndex, setCurrentIndex] = useState(null);//* Set a random responseconst generateResponse = () => {setCurrentIndex(getRandomIndex())}return (<Container><Ball><Window><Dice><Message>{/* Use the current index to grab the message */}{RESPONSES[currentIndex]}</Message></Dice></Window></Ball>{/* Add the onClick handler */}<Button onClick={generateResponse}>pls tell me</Button></Container>);}
And with this, we should have a clickable button that shows us our fortune:
Woo! This is working now, and we could just call it done at this point. Buuuut it's not really exactly how 8-balls work. You've got to shake 'em! The dice disappears while we're shaking and asking our question, so let's add two animations: the ball shaking and the dice fading in and out.
When we click the button to show us our fortune, we'll want to do a few things.
In our component, we'll add state for what state our 8-ball app is in - shaking
or showing
- and add some logic to the generateResponse
function:
// App.js// ...const Dice = styled.div``//* Add the various possible states of the 8-ballconst EIGHT_BALL_STATES = {SHOWING: 'showing', SHAKING: 'shaking'}export default () => {const [currentIndex, setCurrentIndex] = useState(null);//* default to the state of 'showing'const [currentState, setCurrentState] = useState(EIGHT_BALL_STATES.SHOWING);//* Set a random responseconst generateResponse = () => {// mark the ball as shakingsetCurrentState(EIGHT_BALL_STATES.SHAKING);// after one second, generate a new response and show the answer againwindow.setTimeout(() => {setCurrentIndex(getRandomIndex())setCurrentState(EIGHT_BALL_STATES.SHOWING);}, 1000);}return (<Container><Ball><Window>{/* Show the dice only when we're in the SHOWING state */}<Dice isShowing={currentState === EIGHT_BALL_STATES.SHOWING}><Message>{/* Use the current index to grab the message */}{RESPONSES[currentIndex]}</Message></Dice></Window></Ball>{/* Add the onClick handler */}<Button onClick={generateResponse}>pls tell me</Button></Container>);}
Okie dokes! Let's see where that gets us:
Just a quick aside:
You'll notice I went with using two possible states of showing
and shaking
here instead of just making a boolean flag. I only recently started handling states this way, after I read a post by Kyle Shevlin about enumerating states instead of adding boolean flags to see that the possible states route is more extensible.
If we get a third state - maybe a starting
state where we just want to show a blank dice - we could have two booleans now - isShowing
and isInStartingState
. With two booleans, we have two sets of two possible states - true
and false
for both isShowing
and isInStartingState
. That's four possible states. It would be impossible to be in a state where isInStartingState && !isShowing
, but that wouldn't be abundantly clear of when just reading through the code.
You would need to have context and the knowledge that there are still only three states - starting state, showing a fortune state, and generating a fortune state. And if we get a fourth option, we'd have three boolean flags. Eight possible combinations, even when there are only 4 states we care about. A fifth state for this setup would mean 16 combinations, only five of which are cared about.
But by enumerating the possible states - shaking, showing, starting, etc - we can more explicitly code those exact states.
Is this important? In this particular instance with just two states, no, not really. But if this were a huge app that we had a team working on, the enumeration would be easier to understand than a bunch of boolean flags. Kyle's post uses a form example, which makes the use case more clear.
Either way, we now have a ball that will hide and show a new response when we click on it. Onto the next animation!
It would be pretty amazing if we could shake the dice in a real 8-ball without moving it at all. Since I'm not able to with a physical ball, I'd like to make my digital 8-ball shake, too.
To do so, we're going to use the framer-motion
library, which lets you declaratively create animations. It's an awesome, super powerful library. We're just going to scratch the surface here.
For our animation, we need three things:
div
to a motion.div
variants
prop on the Ball
, to describe the different animation states we wantanimate
prop on the Ball
, which is the label for the current animation state we're inThe library decorates normal div
s (and other html tags) so that they are able to be animated. Since we're updating the Ball
to shake, we'll change its declaration to a motion.div
:
- const Ball = styled.div`...`+ const Ball = styled(motion.div)`...`
Next, we choose our variants. To shake, we're just going to be adjusting the x
and y
values of the ball. In the showing state, we want no animations to be on, so it will be {x: 0, y: 0}
. For the shaking state, we want to do a little more. We can pass an array of x
and y
values, and the framer-motion
library will create keyframes for each value. We could use {x: [-10, 10, -10, 10], y: 0}
, and it would shake the ball 10 pixels left, then 10 pixels right, then 10 pixels left, and then finally finish at 10 pixels to the right. I just added a few random numbers, alternating positive and negative for both x
and y
so that the ball would be moving up, down, left and right. Play around with it to make the shake more subtle or extreme!
const ballVariants = {[EIGHT_BALL_STATES.SHAKING]: {x: [10, -16, 10, -12, 19, -10, 4, -10, 0], y: [10, -9, 5, -10, -6, -10, 6, -10, 6, 0],},[EIGHT_BALL_STATES.SHOWING]: { x: 0, y: 0 },}
We can pass these variants to the Ball
component, along with the current state of the app as the animate
property (shaking or showing):
// App.js// ...const Ball = styled.(motion.div)` ... `const EIGHT_BALL_STATES = {SHOWING: 'showing', SHAKING: 'shaking'}//* Add the animation variants for the possible statesconst ballVariants = {[EIGHT_BALL_STATES.SHAKING]: {x: [10, -16, 10, -12, 19, -10, 4, -10, 0],y: [10, -9, 5, -10, -6, -10, 6, -10, 6, 0],},[EIGHT_BALL_STATES.SHOWING]: { x: 0, y: 0 },}export default () => {const [currentIndex, setCurrentIndex] = useState(null);const [currentState, setCurrentState] = useState(EIGHT_BALL_STATES.SHOWING);const generateResponse = () => {...}return (<Container>{/* Add the variants and animate values to the ball */}<Ballvariants={ballVariants}animate={currentState}><Window><Dice isShowing={currentState === EIGHT_BALL_STATES.SHOWING}><Message>{RESPONSES[currentIndex]}</Message></Dice></Window></Ball><Button onClick={generateResponse}>pls tell me</Button></Container>);}
After this, we've got a fully funtional ball. You could add some more animations- flash different colors while it's shaking, grow or shrink - whatever you want!
Also, as a note - when we provide an array of keyframes like we did for the x
and y
values, the library defaults to a 0.8s
duration. If you want to make it faster or slower, you can adjust the transition
property on the Ball
:
<Ballanimate={currentState}variants={ballVariants}+ transition={{duration: 3}} // 3 second duration>
You can call yourself done and pat yourself on the back here. Or, if you want to add a little more depth to the ball's appearance, we can add some extra styling gradients and shadows. All of the changes here are just going to be to the Ball
component.
First up, we'll give the full ball a subtle radial gradient background, instead of just the black background. We're moving the starting point to 60% 130%
so that the gradient's center is off to the right a bit and way down below the ball. This makes the effect more subtle.
const Ball = styled.div`- background: #0b0b0b;+ background: radial-gradient(circle at 66% 130%, #333, #0a0a0a 80%, #000000 100%);height: 600px;width: 600px;border-radius: 50%;position: relative;display: flex;align-items: center;justify-content: center;`
Next, we'll add a mostly-transparent overlay so that we can have a dark lip at the bottom of the ball, which will make it look like it's more of a sphere than a circle. This will be as a :before
pseudo-element, which you can just throw in the styled component as you would in CSS.
const Ball = styled.div`background: radial-gradient(circle at 66% 130%, #333, #0a0a0a 80%, #000000 100%);height: 600px;width: 600px;border-radius: 50%;position: relative;display: flex;align-items: center;justify-content: center;// Add the before element&:before {content: "";position: absolute;background: radial-gradient(circle at 66% 130%, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 70%);border-radius: 50%;/* move the gradient up and left so that we leave the dark edge at the bottom */bottom: 2%;left: 3%;opacity: 0.6;height: 100%;width: 95%;filter: blur(5px);z-index: 2;}
I'd encourage you to add and remove and change the values in the :before
element so that you can see how everything's working - the opacity on the radial gradient, the bottom
and left
, the width, and the filter
Last up, we want to add that small white spot as a reflection of a light source. This one we can do as an :after
pseudo-element.
const Ball = styled.div`background: radial-gradient(circle at 66% 130%, #333, #0a0a0a 80%, #000000 100%);height: 600px;width: 600px;border-radius: 50%;position: relative;display: flex;align-items: center;justify-content: center;&:before {/*...*/}// Add the after element&:after {content: "";width: 50%;height: 50%;position: absolute;top: 0;left: 0;border-radius: 50%;background: radial-gradient(circle at 50% 50%,rgba(255,255,255,0.8), rgba(255,255,255,0) 33%);transform: skewX(-20deg);filter: blur(10px);}
Again, play around with all of the CSS properties if they're not super familiar. We're making a circle that goes from white to transparent and then skewing it to look more like an oval.
That's about all, folks. Play around with the responses, the animations, the shading, and let me know what you make! Thanks for following along ✌️