How To Animate a Winners' Podium
Create a simple animated winners' podium from scratch using React and Framer Motion
Gamification! ... was a buzzword a few years ago. Despite that, it's still common to find gamified elements in Apps and SaaS products today—game elements like points, rankings against others, etc. In this article we're going to look at how we can create a winners' podium that animates winners one by one for added dramatic effect. We'll be using React and an open source library called Motion. At the end of this article you'll see how simple it is to build something with fancy animations. I'll assume that you have no more than basic React experience.
Framer Motion is an animation and gesture library for React from the company that makes Framer, the popular prototyping tool. It lets us create animations in a declarative way in React using simple components like <motion.div />
.
I'm not a designer, and the point of this article isn't to blow you away with my sexy design skills (which I don't have.) Instead, we want to look at how simple it is to make an interesting animation that helps communicate useful information to users in an engaging, fun way.
If you'd like to just jump straight to a fully working demo, you'll find a Fancy version and a Simple version on CodeSandbox.
Now, lets dive in!
A bit of setup
First, we need to setup a React project. We can use Create React App, or something like Codesandbox—doesn't matter.
Either:
npx create-react-app podium-thingy
Or, create a new Sandbox on Codesandbox using the basic React template
Or, fork my sandbox which already contains all the code in this article
With the project scaffolded, we next need to add the framer-motion
dependency to the project (npm install framer-motion --save
).
Great. Boring stuff done.
A bit of dummy data
Before we get to the animation code, we need to create a bit of fake winner data. In a real-world scenario, we might be fetching this data from a backend API, but here, we'll just import it as Javascript.
Create a file, src/data.js
and copy the following data into it. The order of the data in the array will correspond to the ranking of the winners; the first item in the array is 1st place, the second, 2nd place, etc. Our podium will support up to 10 winners. Everyone's a winner! 🤮
src/data.js
export default [
{
id: '1tfjiELNrwYAJeafRYlT9RwOIiD',
name: 'Grace Hopper',
},
{
id: '1tfjiFoinFrbdLWlPI52dRLhNlD',
name: 'Yoshitake Miura',
},
{
id: '1tfjiDIAS8f2UYgV9ynCqWi7rZD',
name: 'Ada Lovelace',
},
{
id: '1tfjiEIWBZz2I9lOQYTEeMICALg',
name: 'Grete Hermann',
},
{
id: '1tfjiCMU9SdFM9BAaIF3mS5UpYf',
name: 'Chieko Asakawa',
},
].map((winner, position) => ({ ...winner, position }))
The Podium Step
We want each winner to be raised up to their position individually starting from the last place to ending with the first place winner. As each winner is "announced" we want their profile picture to fade in for added dramatic effect. How do we do this?
The podium breaks down into two components: the podium's wrapping structure (<Podium />
) and the individual steps for each winner (<PodiumStep />
). The podium step is the component we want to animate.
We'll build the code from the bottom-up, so we'll first look at the <PodiumStep />
component, which is where most of the relevant code is.
Create a new file, src/PodiumStep.js
with the following code:
src/PodiumStep.js
import { motion } from 'framer-motion'
export default function PodiumStep({ podium, winner }) {
const offset = podium.length - winner.position
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
placeContent: 'center',
}}
>
<motion.div
style={{
alignSelf: 'center',
marginBottom: '.25rem',
}}
initial="hidden"
animate="visible"
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
delay: 1 + (offset + 2),
duration: 0.75,
},
},
}}
>
<img
src={`https://i.pravatar.cc/64?u=${winner.id}`}
alt=""
style={{
borderRadius: 9999,
height: '2.75rem',
overflow: 'hidden',
width: '2.75rem',
}}
/>
</motion.div>
<motion.div
style={{
backgroundColor: 'rgba(219,39,119,1)',
borderColor: 'rgba(190,24,93,1)',
borderTopLeftRadius: '.5rem',
borderTopRightRadius: '.5rem',
display: 'flex',
filter: `opacity(${0.1 + offset / podium.length})`,
marginBottom: -1,
placeContent: 'center',
width: '4rem',
}}
initial="hidden"
animate="visible"
variants={{
hidden: { height: 0, opacity: 0 },
visible: {
height: 200 * (offset / podium.length),
opacity: 1,
transition: {
delay: 1 + offset,
duration: 2,
ease: 'backInOut',
},
},
}}
>
<span style={{ alignSelf: 'flex-end', color: 'white' }}>
{winner.position + 1}
</span>
</motion.div>
</div>
)
}
What's going on here?
Motion lets us animate between variations. Typically, one wants to animate between two states: an initial "hidden" state, and a final "visible" state. Motion lets us configure, through CSS styling, what the element should look like at each of these states, and then handles transitioning the styling between the two states for us.
<motion.div
initial="hidden"
animate="visible"
variants={{
hidden: {
// css styling of elements initial hidden state
},
visible: {
// css style to transition to when element is visible
},
}}
/>
In the podium step, we have two things we want to animate: the winner's profile picture, and the podium step itself. Consequently, we wrap the profile image and the podium step in two <motion.div />
components, respectively.
The first component is for the winner's profile.
<motion.div
initial="hidden"
animate="visible"
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
delay: 1 + (offset + 2),
duration: 0.75,
},
},
}}
/>
The animation here is a simple fade-in. We're transitioning from a hidden to a visible state during which Motion fades the opacity from 0 (transparent) to 1 (opaque). We also delay the transition so that it coincides with the winner's podium step animation completing.
The second use of the Motion component is for the winner's individual podium step. This animates first.
<motion.div
style={{
.....
filter: `opacity(${0.1 + offset / podium.length})`,
}}
initial="hidden"
animate="visible"
variants={{
hidden: { height: 0, opacity: 0 },
visible: {
height: 200 * (offset / podium.length),
opacity: 1,
transition: {
delay: 1 + offset,
duration: 2,
ease: 'backInOut',
},
},
}}
>
Here, the animation transitions a <div />
that has no height, to one which has a computed height proportional to the winner's position on the podium. For example, the step of the first place winner is higher than the step of second place.
The Podium Structure
Next, we need to render our podium. Create a new file, src/Podium.js
and copy the following into it:
src/Podium.js
import PodiumStep from './PodiumStep'
export default function Podium({ winners }) {
const podium = [8, 6, 4, 2, 0, 1, 3, 5, 7, 9]
.reduce(
(podiumOrder, position) => [
...podiumOrder,
winners[position],
],
[],
)
.filter(Boolean)
return (
<div
style={{
alignContent: 'flex-end',
alignItems: 'flex-end',
borderBottom: '1px solid #e5e7eb',
display: 'grid',
gap: '.5rem',
gridAutoFlow: 'column dense',
justifyContent: 'center',
justifyItems: 'center',
height: 250,
marginTop: '2rem',
}}
>
{podium.map((winner) => (
<PodiumStep
key={winner.id}
podium={podium}
winner={winner}
/>
))}
</div>
)
}
In the <Podium />
component we're iterating over the winners' data we created earlier and rendering a <PodiumStep />
for each winner.
One interesting bit of work this component does is organizing the podium:
const podium = [8, 6, 4, 2, 0, 1, 3, 5, 7, 9]
.reduce(
(podiumOrder, position) => [
...podiumOrder,
winners[position],
],
[],
)
.filter(Boolean)
The first bit, [8, 6, 4, 2, 0, 1, 3, 5, 7, 9]
, sets up how our podium is layed out. Rather than having a left-to-right placement of the 1st, 2nd, 3rd, 4th, etc. place positions, we want to stagger them left and right, with the first place in the center, and lower ranks towards the outside. You know. Like a typical winner podium. We could have created a fancy algorithm to compute this for us, but the beauty here is in the simplicity of listing out the positions in an array. It's visually clear to us in code how the podium will be structured. And, if we wanted a different layout, we could move the numbers around as desired. Try it!
Tying It All Together
Finally, a quick visit to App.js
to import our winner data and add our Podium
component.
src/App.js
import podiumData from './data'
import Podium from './Podium'
import './styles.css'
export default function App() {
return (
<div className="App">
<Podium winners={podiumData} />
</div>
)
}
Yours doesn't work? Something went wrong? You'll find full code and working demo on CodeSandbox. A simple version and a fancier version which uses Tailwind for styling are available.
That's it. We've got our podium. Next steps from here? Try adding a list of winners which slide in as the podium step appears for each winner! I'll leave that up to you 😉. Check the fancy demo for a solution.
This article is part of my 30 days / 30 articles challenge where I've attempted to write thirty articles within thirty days.