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.
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:
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.