Building progressively-enhanced iPadOS hover style in web

Using framer-motion for Animation & Transition

  • Published at 
  • 12 minutes reading time

One of the key features from new iPadOS 13.4 announcement was that it finally has proper mouse and trackpad support. 1 thing that stood out to me was how the cursor morph to cover touchable areas. You might think it's just a glorified hover effect, but if you look closely, there are subtle visual cues that makes the behavior feels fluid and natural. One of them is how the highlighted area move slightly alongside cursor movement.

iPadOS’ new cursor at work courtesy of MacStories

I'm far from what you call UI/UX expert. I'm just a kind of people that likes to copy what I like. This hover effect is no different. Since revamping my blog, I have growing interest in micro interaction, and I think this hover effect is a great addition.

Replicating the behavior

First step to do is how to replicate the behavior. I'm using framer-motion but it can also be implemented using any spring-based animation library.

The behavior we want to replicate is how cursor movement inside touchable area affects the position of the highlighted area:

Screen recording of iPad hover behavior when cursor is moving in the highlighted area

To do that, we're going to track mouse position relative to touchable area. We do that by subtracting e.clientX / e.clientY (which track mouse position relative to the viewport) with e.currentTarget.offsetTop / e.currentTarget.offsetLeft which reflect the absolute position of the touchable area in the viewport.

We're using MotionValue to store the mouse position because we're going to interpolate them later on.

import { motion, useMotionValue } from 'framer-motion';
function FancyHoverLink(props) {
const mx = useMotionValue(0);
const my = useMotionValue(0);
return (
<motion.a
key="root"
onMouseMove={(e) => {
const { offsetTop, offsetLeft } = e.currentTarget;
// we get [mx, my] value that track relative cursor position in the touchable area
// [0, 0] means the cursor is at top left of the touchable area
mx.set(e.clientX - offsetLeft);
my.set(e.clientY - offsetTop);
}}
>
{props.children}
</motion.a>
);
}

We're also going to need width and height from the touchable area using ClientRect to detect intent of cursor direction. We also need this to determine the size of the highlighted area. We use absolute-positioned div for the highlighted area so it acts as a background that we can later move using top/left property.

import { motion } from 'framer-motion';
function FancyHoverLink(props) {
const [[width, height], setTouchableSize] = React.useState([0, 0]);
const measuredRef = React.useCallback((node) => {
if (node !== null) {
const rect = node.getBoundingClientRect();
setTouchableSize([rect.width, rect.height]);
}
}, []);
return (
<motion.a
key="root"
ref={measuredRef}
style={{ position: 'relative', cursor: 'none' }}
>
<motion.div
key="highlight"
style={{
position: 'absolute',
width,
height,
}}
/>
{props.children}
</motion.a>
);
}

The next step is adding animation. If you look closely in iPadOS hover style, both highlighted area and the content inside of it both animate by following the cursor movement. The mapping is not 1:1, which means for example when the cursor move 4px to the left, the highlighted area doesn't move 4px. We're going to use interpolation for this (this is why we're using MotionValue before).

First we create interpolation for movement in highlighted area. We're using useTransform which accept MotionValue as first argument, input range, and the output range. This function will map the input to the output based on speed & velocity on the MotionValue so it's guaranteed have the same spring behavior.

In this case we're going to move the highlighted area up by 3px if the cursor on the topmost of touchable area, and move it by 3px down if the cursor is at the bottom of the touchable area (or as high as its height). We do the same to the left position.

import { motion, useTransform } from 'framer-motion';
function FancyHoverLink(props) {
const moveTop = useTransform(my, [0, height / 2, height], [-3, 0, 3]);
const moveLeft = useTransform(mx, [0, width / 2, width], [-3, 0, 3]);
return (
<motion.a key="root">
<motion.div key="highlight" style={{ top: moveTop, left: moveLeft }} />
{props.children}
</motion.a>
);
}

We also need to create interpolation for the content itself. It follows similar rule, with minor difference: the scale. We're going to use 1px instead of 3px. For this purpose we're also going to render the children inside motion.div component.

import { motion, useTransform } from 'framer-motion';
function FancyHoverLink(props) {
const contentMoveTop = useTransform(my, [0, height / 2, height], [-1, 0, 1]);
const contentMoveLeft = useTransform(mx, [0, width / 2, width], [-1, 0, 1]);
return (
<motion.a key="root">
<motion.div key="highlight" />
<motion.div
key="content"
style={{
position: 'relative',
top: contentMoveTop,
left: contentMoveLeft,
}}
>
{props.children}
</motion.div>
</motion.a>
);
}

Next, we're going to show the highlighted area only on hover. To do this, we're going to setup 2 things. First we pass initial and variants props to the highlight component so it can change to different style based on the state, then we add withHover prop to the root to change the state.

import { motion } from 'framer-motion';
function FancyHoverLink(props) {
const variants = {
blur: { opacity: 0 },
hover: { opacity: 0.1 },
};
return (
<motion.a withHover="hover">
<motion.div
key="highlight"
initial="blur"
variants={variants}
style={{ backgroundColor: '#000' }}
/>
<motion.div key="content">{props.children}</motion.div>
</motion.a>
);
}

And that's basically it. In the code examples above I hide few things that not relevant so you can focus on the addition that I made, but you can see the demo in full in this Codesandbox.

All the animations and transitions are already handled by framer-motion. Similar to declarative nature of React, framer-motion provides us a way to express animation and transition in a declarative way.

Progressive Enhancement

Static sites, especially news and blogs should be accessible without JavaScript. Because framer-motion is a JavaScript library, we lost the hover effect when the JavaScript not available / still being loaded. To fix this, we're going to create a fallback component that's rendered server-side (or in my case statically generated at compile time).

This fallback uses CSS :hover state so it always works.

import styled from 'styled-components';
const FallbackLink = styled.a`
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`;

The reason we use different component is because the fallback uses background-color in the root node, while the fancy version uses no background color in the root node and empty div as children with absolute positioning. We don't want the hover behavior from root node to be applied in the fancy component.

Code Splitting and Loading

Considering we only use this component in small part of the screen, it would be a waste to force it on initial load. After all, this is just a nice-to-have micro interaction. Even worse, framer-motion itself is almost 30kB gzipped. This is as big as react-dom itself.

I'm using next.js, so my first thought was to use next.js dynamic import, and only render the FancyHover component in the client-side after initial mount:

const FancyHoverLink = dynamic(import('./FancyHoverLink'), {
loading: FallbackLink,
});
function Link(props) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <FallbackLink {...props} />;
}
return <FancyHoverLink {...props} />;
}

This looks complicated, but it gets the job done. We set loading property to use FallbackLink component because otherwise we render null while we're waiting for FancyHover module to be loaded.

The reason we need mounted flag is because we want the assets to be lazily loaded in the client. If we simply render FancyHoverLink with loading property set, next.js will now that it's actually needed in initial render and bundle the assets into critical bundle. This is not the behavior that we want.

We also can't simply use typeof window !== 'undefined check for rendering client-side components because it might mess with the hydration

The better alternative would be using React.Suspense and React.lazy. Because next.js uses ReactDOM.renderToString to generate static markup, it will always use fallback. When hydrating the component in client-side, it renders FancyHoverLink lazily while still displaying the fallback until the dynamic import is resolved.

const FancyHoverLink = React.lazy(() => import('./FancyHoverLink'));
function Link(props) {
return (
<React.Suspense fallback={<FallbackLink {...props} />}>
<FancyHoverLink {...props} />
</React.Suspense>
);
}

Compared to previous version, it's much cleaner approach. The only downside is I have to use experimental version of React because the Suspense server renderer is not available in stable release yet.

Webmentions