Building a Magic 8-Ball with React and Framer Motion

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:

Install dependencies

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 yarn
yarn add framer-motion styled-components
// if you're using npm
npm 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-app
npx create-react-app eight-ball
// move into the directory
cd eight-ball
// install dependencies
yarn 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.

Build the basic markup

Now that we're all set up, let's get to building. We'll make a few styled components along the way:

// App.js
import React from 'react'
import styled from 'styled-components'
const Container = styled.div`
display: flex;
align-items: center;
flex-direction: column;
height: 600px;
`
const Ball = styled.div`
background: #0b0b0b;
height: 600px;
width: 600px;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
`
const Window = styled.div`
background-color: #010221;
height: 200px;
width: 200px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
z-index: 3;
border: 5px solid #111;
box-shadow: inset 0px 0px 2px 1px #3e3e3e;
`
const Dice = styled.div`
background: #040bbf;
height: 100px;
width: 100px;
display: flex;
border-radius: 10px;
align-items: center;
justify-content: center;
text-align: center;
`
const Message = styled.div`
color: #82d6ff;
padding: 10px;
font-size: 11px;
font-weight: bold;
letter-spacing: -0.2px;
text-transform: uppercase;
`
const Button = styled.button`
background: #040bbf;
color: #82d6ff;
padding: 15px 30px;
border-radius: 5px;
margin-top: 15px;
transition: 0.3s all ease;
font-weight: bold;
&:hover {
cursor: pointer;
background: #030be0;
}
`
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:

You can bet on it.

Give the user their fortune

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.js
import React from 'react'
import React, { useState } from 'react' //* add useState
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`...`
//* Add our list of possible responses
const 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.length
const getRandomIndex = () => Math.floor(Math.random() * responses.length)
export default () => {
//* Add state tracker
const [currentIndex, setCurrentIndex] = useState(null);
//* Set a random response
const 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:

Add some animations

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.

Dice disappearing

When we click the button to show us our fortune, we'll want to do a few things.

  1. First, we should hide the dice.
  2. While hidden, we should change the fortune.
  3. Fade the dice back in.

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`
/* ... */
/* show or hide the dice based on the app state */
opacity: ${props => props.isShowing ? 1 : 0};
transition: 0.5s all ease;
`
//* Add the various possible states of the 8-ball
const 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 response
const generateResponse = () => {
// mark the ball as shaking
setCurrentState(EIGHT_BALL_STATES.SHAKING);
// after one second, generate a new response and show the answer again
window.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:

Enumerate states instead of make a boolean

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!

Shaking the ball

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:

  1. to change the component from a normal div to a motion.div
  2. a variants prop on the Ball, to describe the different animation states we want
  3. an animate prop on the Ball, which is the label for the current animation state we're in

The library decorates normal divs (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 states
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 },
}
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 */}
<Ball
variants={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:

<Ball
animate={currentState}
variants={ballVariants}
+ transition={{duration: 3}} // 3 second duration
>

Make the 8-ball look a little more 3D

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;
`
You can bet on it.

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

You can bet on it.

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 ✌️

©2020 Matthew Knudsen