← All notes

Menus that know which way you went

Two menus, one rule: what comes in should come from where you’re going.

Menus mostly switch: one panel disappears, another one appears, and you rebuild the picture in your head. These two keep the picture. Start with Motion Tabs Menu, and go from one to another in both directions.

One number for the direction

Everything depends on a single value: are you going right or left? The new panel comes in from that side, 60% of its width away, and the old one leaves by the other. It’s the same variant in both cases, multiplied by plus or minus one.

motion-tabs-menu.tsx
const contentVariants = {
  initial: (direction: number) => ({ x: `${60 * direction}%`, opacity: 0 }),
  active: { x: "0%", opacity: 1 },
  exit: (direction: number) => ({ x: `${-60 * direction}%`, opacity: 0 }),
};

setDirection(index > selected ? 1 : -1);

Sixty and not a hundred, so the two panels overlap while they cross. A full push reads as two separate things going past each other. This reads as one thing changing.

Measuring something that’s moving

The menu’s height follows its content, which means measuring it, and the trap is measuring the wrong element. Put the ruler on the part being animated and it reads its own animation. So it sits on a plain wrapper inside, and the last good height is kept for the moment a panel leaves and the content is briefly nothing.

motion-tabs-menu.tsx
// Keep last stable content height to avoid jumps during AnimatePresence exit transitions
useEffect(() => {
  if (bounds.height > 0) setLastContentHeight(bounds.height);
}, [bounds.height]);
const contentHeight = bounds.height > 0 ? bounds.height : lastContentHeight;

The width I don’t measure at all. It’s the width of the closed bar plus a guess: seven pixels per letter of the open label, plus padding. It’s crude, and it’s also the only way to know the final width before the label has appeared. The label waits 80 milliseconds, so it opens into room that’s already there.

The same idea, going deeper

Motion Slide Menu does this vertically through levels. It’s the same rule: forward comes from the right, back comes from the left. Since a demo with a made-up dashboard would prove nothing, this one is this site. Everything in it really goes somewhere.

The detail I like most is the row you just clicked. It doesn’t leave with the others. It stays, moves to the top and becomes the way back, and its icon turns into a chevron. So you can always see where you are, because the thing you chose is still in front of you.

motion-slide-menu.tsx
const visibleItems = activeCategory
  ? [activeCategory, ...currentItems]
  : currentItems;

// same key as before, so it travels instead of being replaced
<motion.li layout="position" layoutDependency={panelKey} key={item.id} />

Animation is for pointers

Someone using the arrow keys isn’t watching a menu slide, they’re trying to get somewhere. So the menu remembers how you last used it. Press a key and it stops animating altogether, moves focus to the first row of the new level, and answers to the arrows, Home, End and Escape. Touch it with a pointer and the motion comes back.

motion-slide-menu.tsx
const inputMethod = React.useRef<"keyboard" | "pointer">("pointer");
const keyboardNavigation = inputMethod.current === "keyboard";
const disableAnimation = keyboardNavigation;

If you’ve asked your system for less motion, the rows stop traveling too, and only fade. The direction was never the content. It’s only there for people who are watching.