Toolbar

General purpose toolbar component that animates width, height or both.

Structure

This component consists of three main parts: the base, the content and the container that wraps everything.

Base

The base is where all of the buttons live. This component persists across states all of the states and it's very straightforward. I map over the buttons, assign icons 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 }}
/>
Content

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 & Width

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.

Container

The container handles the enter/exit and height/width animations of the toolbar. The opacity, scale, translateY 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)",
    translateY: "24px",
    scale: 0.5,
  }}
  animate={{
    opacity: 1,
    scale: 1,
    translateY: "-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,
    translateY: { type: "spring", bounce: 0, duration: 0.35 },
    scale: { type: "spring", bounce: 0, duration: 0.35 },
  }}
  exit={{
    opacity: 0,
    scale: 0.5,
    translateY: "24px",
    filter: "blur(4px)",
  }}
  className="animated-container"
/>

In terms of the transition settings, I used different transition values for scale and translateY because the expanding animation was a bit slower and more bouncy than I wanted the toolbar enter/exit animation to be.

Final Touches

Finally, the entire component is wrapped in AnimatePresence to handle the enter/exit animation of the toolbar itself.

<AnimatePresence initial={false}>{isExpanded && <Toolbar />}</AnimatePresence>
More

In case you have any questions reach me at jakub@kbo.sk or see more of my work on Twitter.