Toolbar
General purpose toolbar component that animates width, height or both.
This component consists of three main parts: the base, the content and the container that wraps everything.
The base is where all of the buttons live. This component persists
across states and it's very straightforward. I map
over the buttons, assign icon to each button and attach the correct onclick
handler to expand the
corresponding content.
<div className="button-container">
{buttons.map((button, index) => (
<Button
key={index}
icon={button.icon}
onClick={() => handleButtonClick(index)}
isActive={activeIndex === index}
/>
))}
</div>
For the active state, I used an absolutely positioned div
with a shared layoutId
. This way the background
animates between buttons when changing the selected state.
<motion.div
layoutId="toolbar-button-background"
className="absolute inset-0 size-10 rounded-full bg-[#333]"
transition={{ type: "spring", bounce: 0, duration: 0.35 }}
/>
The content for each state is a separate component.
const Content1 = () => {
return (
<div className="content1-container">
<CircleIcon icon={<ShieldIcon className="icon" />} />
</div>
);
};
These components are then used in a switch statement to render the
correct one based on the index. This would be a good place to use useMemo
to optimize performance,
but I use the React compiler, so these get optimized automatically.
const renderContent = () => {
if (activeIndex === null) return null;
const isInitial = previousIndex === null;
const content = (() => {
switch (activeIndex) {
case 0:
return <Content1 />;
case 1:
return <Content2 />;
case 2:
return <Content3 />;
case 3:
return <Content4 />;
default:
return null;
}
})();
};
The content object is then used in a motion.div
that handles the
animations through variants, assigns a key based on the index and
handles custom props like direction
, isInitial
and isCollapsing
.
<motion.div
key={activeIndex}
variants={variants}
initial="initial"
animate="animate"
exit="exit"
custom={{ direction, isInitial }}
transition={{
type: "spring",
bounce: 0,
duration: 0.5,
}}
className="animated-container"
>
{content}
</motion.div>
As outlined above, the animations are defined through variants. The main
properties animated are x
and opacity
. Custom props like direction
, isInitial
and isCollapsing
are used to control
animation behavior.
direction
makes this animation direction aware. I've previously struggled with
finding a way to make this direction aware in a way that makes sense and isn't
hacky, but Animations on the Web by Emil
Kowalski helped a lot.
isInitial
determines whether the toolbar is collapsing from or to null
and it prevents the motion.div
from animating the
x
value if that is the case.
isCollapsing
is used to remove the container instantly when the toolbar is collapsing. This is done by
setting the duration of the opacity
transition to 0.
const variants = {
initial: ({
direction,
isInitial,
}: {
direction: number;
isInitial: boolean;
}) => ({
x: isInitial ? 0 : `${110 * direction}%`,
opacity: 0,
}),
animate: () => ({
x: 0,
opacity: 1,
}),
exit: ({
direction,
isInitial,
isCollapsing,
}: {
direction: number;
isInitial: boolean;
isCollapsing: boolean;
}) => ({
x: isInitial ? 0 : `${-110 * direction}%`,
opacity: isCollapsing ? 0 : 1,
transition: {
opacity: {
type: "spring",
bounce: 0,
duration: isCollapsing ? 0 : 0.6,
},
},
}),
};
The renderContent
function is wrapped in AnimatePresence
with direction
, isInitial
and isCollapsing
passed through the
custom
prop. All of this is placed in a div
.
<div className="container" ref={ref}>
<AnimatePresence
mode="popLayout"
initial={false}
custom={{
direction,
isInitial: activeIndex === null,
isCollapsing: activeIndex === null,
}}
>
{renderContent()}
</AnimatePresence>
</div>
Animating height in Motion can get tricky at times if you decide to animate from and to auto
.
To make sure both of the values I'm animating between are defined values, I used the useMeasure
hook from useHooks
to calculate the height of the content.
const [ref, bounds] = useMeasure();
The ref
is used on the content container to measure its height.
<div className="container" ref={ref}>
<AnimatePresence
mode="popLayout"
initial={false}
custom={{
direction,
isInitial: activeIndex === null,
isCollapsing: activeIndex === null,
}}
>
{renderContent()}
</AnimatePresence>
</div>
The measured value is then used in the height animation of the outer motion.div
that wraps the content.
I separated the height of the content and the height of the base, which is why I also have to add the
BASE_HEIGHT
to the measured value.
<motion.div
animate={{
height: activeIndex !== null ? bounds.height + BASE_HEIGHT : BASE_HEIGHT,
}}
/>
For animating width, you could use the exact same approach as for the height, as far as there is any content that defines the width of the content container. However, for this example I just decided to go with fixed widths, that are assigned based on the index of the expanded content.
const contentWidths = {
0: "320px",
1: "280px",
2: "240px",
3: "320px",
};
With this setup the content can be anything and the component automatically calculates the correct height, although you might need to adjust the transition settings based on the size.
The container handles the enter/exit and height/width animations of the toolbar. The opacity
, scale
, y
and
filter
are all animated and used to create the toolbar appearing and disappearing animation.
In addition, width
and height
which were covered earlier in the article, are also animated as well as borderRadius
.
<motion.div
initial={{
opacity: 0,
filter: "blur(4px)",
y: "24px",
scale: 0.5,
}}
animate={{
opacity: 1,
scale: 1,
y: "-24px",
filter: "blur(0px)",
width:
activeIndex !== null
? contentWidths[activeIndex as keyof typeof contentWidths]
: "",
height: activeIndex !== null ? bounds.height + BASE_HEIGHT : BASE_HEIGHT,
borderRadius: activeIndex !== null ? "24px" : "32px",
}}
transition={{
type: "spring",
bounce: 0.25,
duration: 0.5,
y: { type: "spring", bounce: 0, duration: 0.35 },
scale: { type: "spring", bounce: 0, duration: 0.35 },
}}
exit={{
opacity: 0,
scale: 0.5,
y: "24px",
filter: "blur(4px)",
}}
className="animated-container"
/>
In terms of the transition settings, I used different transition values for scale
and y
because the expanding
animation was a bit slower and more bouncy than I wanted the toolbar enter/exit animation to be.
Finally, the entire component is wrapped in AnimatePresence
to handle the enter/exit animation of the toolbar itself.
<AnimatePresence initial={false}>{isExpanded && <Toolbar />}</AnimatePresence>
In case you have any questions reach me at jakub@kbo.sk or see more of my work on Twitter.