Gavin Nelson recently redesigned his portfolio and it's looking ✨crisp✨. I wondered how to get an iPadOS hover effect like that to work and with quite a bit of help of my friend Nils, a few discussions with ChatGPT and finally understanding LayoutGroups in Framer Motion, I present to you this solution. I'm sure it's far worse than Gavin's, so please don't think I know what I'm doing.

The Theory

It took me a while to understand this but when I found a SwiftUI comparison it clicked for me. I'm not sure if this will help anybody else, but I'll document this to solidify this knowledge for myself.

  • Nothing about this is magic. It's a list of bunch of <a> elements containing an icon and the label.
  • The only special thing is an additional div in every <a> that is only rendered on hover.
  • What stumped me at first: This is not one single element being moved from link to link. Every single <a> tag has its own light gray background div.

Here it is, without all the surrounding React and TypeScript nonsense:

<a
  {...props}
  className="group relative py-1 px-2 flex flex-row text-slate-500 transition-colors hover:text-slate-800 items-center gap-2 underline decoration-slate-200 underline-offset-4"
  onMouseOver={() => setHover(true)}
  onMouseOut={() => setHover(false)}
>
  <AnimatePresence>
    {hover && (
      <motion.div
        layoutId="homing-hover"
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        className="absolute rounded-md inset-0 bg-slate-200 -z-10"
        transition={{
          type: "spring",
          stiffness: 200,
          damping: 20,
        }}
      />
    )}
  </AnimatePresence>
  {IconComponent && (
    <IconComponent className="text-slate-400 group-hover:text-slate-600 transition-all group-hover:scale-125" />
  )}
  {children}
</a>

As you can see, onMouseOver sets the hover state to true, onMouseOut to false. With that hover knows when this element has a cursor on top of it. Great.

<AnimatePresence> is Framer Motions solution to conditionally rendered elements being removed from the React tree. Meaning: Since we're using hover as binary indicator for if the background div should be rendered or not, it would plop in and out of existence. If we would not be using AnimatePresence, the disappearance of the div when the mouse leaves the area of the <a> would lead to it not being animated. Or in short: What isn't there can't be pretty.

Said div is not a normal div anymore. We're transforming it into a motion.div and with that it's ready to accept all sorts of Framer Motion properties. The most important one in this case: layoutId="homing-hover". We give it a Layout ID. Framer Motion uses this ID to understand where technically different elements are supposed to function as the same element in terms of transitions.

Everything else is standard Framer Motion, Tailwind and React stuff we don't care about for now.

Here's the whole component:

import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
import { IconType } from "react-icons";
import {
  AnchorHTMLAttributes,
  PropsWithChildren,
  ReactNode,
  useState,
} from "react";

type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
  children: ReactNode;
  icon: IconType;
};

export const HomingHoverLink = ({
  children,
  icon: IconComponent,
  ...props
}: Props) => {
  const [hover, setHover] = useState(false);
  return (
    <a
      {...props}
      className="group relative py-1 px-2 flex flex-row text-slate-500 transition-colors hover:text-slate-800 items-center gap-2 underline decoration-slate-200 underline-offset-4"
      onMouseOver={() => setHover(true)}
      onMouseOut={() => setHover(false)}
    >
      <AnimatePresence>
        {hover && (
          <motion.div
            layoutId="homing-hover"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            className="absolute rounded-md inset-0 bg-slate-200 -z-10"
            transition={{
              type: "spring",
              stiffness: 200,
              damping: 20,
            }}
          />
        )}
      </AnimatePresence>
      {IconComponent && (
        <IconComponent className="text-slate-400 group-hover:text-slate-600 transition-all group-hover:scale-125" />
      )}
      {children}
    </a>
  );
};

export const HomingHoverGroup = (props: PropsWithChildren) => (
  <LayoutGroup>{props.children}</LayoutGroup>
);

The eagle-eyed reader discovered something at the end there. A whole other component!

export const HomingHoverGroup = (props: PropsWithChildren) => (
  <LayoutGroup>{props.children}</LayoutGroup>
);

This is very important for all of this to work. Without <LayoutGroup> we'd only have a sad little gray box, showing up and hiding again, without animating from one link to the next.

LayoutGroup is the container that does all the work to ensure that all elements with the same layoutId are being animated as if they're the same object. With each a having the same layoutId, Framer Motion understands that these are supposed to perform shared layout animations.

This component is used to group all the links into one of those LayoutGroups. Technically it doesn't require a whole new component to achieve this but this cleans this up nicely.

An unhelpful comparison to SwiftUI

For those of you that are well versed in SwiftUI, this is logically the same as defining a Namespace and using .matchedGeometryEffect() in SwiftUI. It took me quite a while to understand this, because it felt counter-intuitive to have two different elements in two different places that somehow magically animate as if they're the same but that's how it works. And I guess it makes sense, somehow. You kind of define the start and the end and some animation magic creates a transition between those two states.

Slapping it all together

This is nothing special, but for sake of completeness, here's the component that actually uses these components. The only thing worth noticing is that HomingHoverGroup obviously has to group all the instances of HomingHoverLink.

import { HomingHoverGroup, HomingHoverLink } from "./HomingHoverLink";
import {
  PiAddressBookBold,
  PiHouseBold,
  PiNotepadBold,
  PiCameraBold,
} from "react-icons/pi";

export default function Navigation() {
  return (
    <main className="h-screen flex items-center justify-center">
      <nav className="p-20 flex flex-col items-start gap-2">
        <HomingHoverGroup>
          <HomingHoverLink href="/?foo" icon={PiHouseBold}>
            Home
          </HomingHoverLink>
          <HomingHoverLink href="/?foo" icon={PiNotepadBold}>
            Posts
          </HomingHoverLink>
          <HomingHoverLink href="/?foo" icon={PiCameraBold}>
            Photography
          </HomingHoverLink>
          <HomingHoverLink href="/?foo" icon={PiAddressBookBold}>
            About
          </HomingHoverLink>
        </HomingHoverGroup>
      </nav>
    </main>
  );
}

I bet there are quite a few things I don't understand about the intricacies of this but who cares. For now I got the little rectangle to move from A to B and it looks nice.