Taking your select component to the next level with framer motion
Kenneth Duverge
April 9, 2024
I've been using framer-motion
the past couple of years now and I'm still amazed at how easy
it is to implement nice subtle animations. Animations can greatly enhance the user experience and give an app a more polished feel, when done right.
For the purpose of this article we're going to focus on the framer-motion
portion of the code.
The component itself is built with shadcn/ui
, tailwind
and radix
.
Grab the component code here
// An object so we can show the full album name in the input
const DOOM_ALBUM_MAP = {
food: 'Mm..Food',
doomsday: 'Operation Doomsday',
madvillainy: 'Madvillainy',
};
const DoomSelect = () => {
const [doomAlbum, setDoomAlbum] = useState('doomsday');
return (
<Select onValueChange={setDoomAlbum} value={doomAlbum}>
<SelectTrigger className="w-[200px] focus:outline-dashed focus:outline-2 focus:outline-offset-2 focus:outline-zinc-100 bg-zinc-700 text-white overflow-hidden">
// We'll get to this part 😉
</SelectTrigger>
<SelectContent className="bg-zinc-700 text-white">
<SelectItem value="food">Mm..Food</SelectItem>
<SelectItem value="doomsday">Operation Doomsday</SelectItem>
<SelectItem value="madvillainy">Madvillainy</SelectItem>
</SelectContent>
</Select>
);
};
Alright so the animation I had in mind was something like a reverse slot machine. We have the old value slide up from the center and out while the new value slides up from the bottom and in.
motion
We'll start off with importing motion
from framer-motion
. This component gives us access to all of the DOM elements (ie. motion.div
, motion.a
etc) with top level motion props. A span
will be good for our use case so let's go with that.
Let's break down each line.
import { motion } from 'framer-motion';
<motion.span
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '-100%', opacity: 0 }}
>
{children}
</motion.span>
initial
initial={{ y: '100%', opacity: 0 }}
This is the starting point of the animation. Since we want the value to slide in from the bottom we'll set
its y
axis to 100% ( height of the content ), and to add a subtle fade-in animation we'll set the opacity to 0
.
animate
animate={{ y: 0, opacity: 1 }}
We've told framer-motion where the animation should start from now we need to tell it where it should animate to.
That's where the animate
prop comes in. By setting y
to 0 and opacity
to 1 we bring the text back into the middle of the input box.
So far so good! Just two props and we're already half way there.
exit
exit={{ y: '-100%', opacity: 0 }}
To get that slide out slot machine effect we need to specify the exit
animation. Set y
to negative
100% to go ⬆️ and fade it out with opacity
0.
Now if you try selecting an item from the dropdown you'll notice the exit
animation is still not working as we'd expect.
The old value exits right away.
Well if we think about it we're trying to keep one value around while the new value animates in. React by itself doesn't know how keep an element in the DOM till after it's exit Animation, it removes it instantly. So how are we going to keep two elements around long enough for their animations to run?
A few things:
- We need to tell
framer-motion
, "Hey, run the exit animation of the old value first and then animate the new value in". - We need to tell
React
to remount this component whenever the value changes
AnimatePresence
AnimatePresence
is a provider component that allows to animate components before they're removed from the tree.
This is directly from the documentation.
AnimatePresence allows components to animate out when they're removed from the React tree.
All we need to do is wrap our motion component in AnimatePresence
and pass in the mode=wait
prop
which tells framer-motion to run the exit animation of the node being removed first.
Putting it altogether we should now have something like this.
const AnimatedValue = ({ children }) => {
return (
<motion.span
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '-100%', opacity: 0 }}
>
{children}
</motion.span>
);
};
const DoomSelect = () => {
const [doomAlbum, setDoomAlbum] = useState('doomsday');
return (
<Select onValueChange={setDoomAlbum} value={doomAlbum}>
<SelectTrigger className="...">
<AnimatePresence mode="wait">
<AnimatedValue>
{DOOM_ALBUM_MAP[doomAlbum]}
</AnimatedValue>
</AnimatePresence>
</SelectTrigger>
<SelectContent className="...">
<SelectItem value="food">Mm..Food</SelectItem>
<SelectItem value="doomsday">Operation Doomsday</SelectItem>
<SelectItem value="madvillainy">Madvillainy</SelectItem>
</SelectContent>
</Select>
);
};
React's key
prop
A neat feature about the key
prop is that we can use it to tell explicitly tell React to re-render a component.
We'll need to specify a unique key, fortunately for us we can use the doomAlbum
state variable.
const DoomSelect = () => {
const [doomAlbum, setDoomAlbum] = useState('doomsday');
return (
<Select onValueChange={setDoomAlbum} value={doomAlbum}>
<SelectTrigger className="...">
<AnimatePresence mode="wait">
<AnimatedValue key={doomAlbum}>
{DOOM_ALBUM_MAP[doomAlbum]}
</AnimatedValue>
</AnimatePresence>
</SelectTrigger>
<SelectContent className="...">
<SelectItem value="food">Mm..Food</SelectItem>
<SelectItem value="doomsday">Operation Doomsday</SelectItem>
<SelectItem value="madvillainy">Madvillainy</SelectItem>
</SelectContent>
</Select>
);
};
Now the animation is working as expected 🎉
Conclusion
Let's recap what we've done:
framer-motion
gives us amotion
component that extends all HTML elements.- To define how we want our component to animate from one point to another we can use the
initial
&animate
prop. - If we want to give a component an
exit
animation we need to specify theexit
prop and wrap our component inAnimatePresence
withmode
set towait
. - Finally, we need to set the
key
prop to kick off theinitial
animation for the new value.
This of course is a simple animation but you can do so much more with framer-motion. I encourage you to check out their documentation and examples of cool stuff people have built.
Resources
If you found this article interesting and want to know when the next one comes out consider dropping your email below.