Jacob Paris
← Back to all content

Build a sticky hover effect with Tailwind and React

One of the first things I noticed upon buying my first iPad was the way buttons looked when I hovered over them. When connected to a keyboard + trackpad, the iPad gets a little mouse cursor, and buttons have a sticky hover effect that follows the cursor.

The first step to building this effect was getting the current mouse coordinates. My suspicions that someone had already built this were confirmed when I found the useMousePosition hook on Josh Comeau's blog.

ts
import { useEffect, useState } from "react"
export function useMousePosition() {
const [mousePosition, setMousePosition] = useState({
x: null,
y: null,
})
useEffect(() => {
const updateMousePosition = (ev) => {
setMousePosition({
x: ev.clientX,
y: ev.clientY,
})
}
window.addEventListener(
"mousemove",
updateMousePosition,
)
return () => {
window.removeEventListener(
"mousemove",
updateMousePosition,
)
}
}, [])
return mousePosition
}

I wasn't sure how many places I would want this effect – for sure on some buttons and some links – so I decided to jump straight to how I would want to use it. After playing with a few different options, I settled on the following pattern.

tsx
import { useRef } from "react"
import { useHoverEffect } from "./useHoverEffect"
export function Button({ children }) {
const elementRef = useRef(null)
const Effect = useHoverEffect(elementRef)
return (
<button
ref={elementRef}
className="translate-0 group relative"
>
<Effect />
{children}
</button>
)
}

The useHoverEffect hook needs to take a ref prop that determines which element to compare the relative mouse position against, and then return a component that renders the hover effect.

Any component that wants a hover effect needs the classes translate-0 group relative.

  • translate-0 creates a new stacking context, so that the hover effect shows up behind the text content
  • group allows us to apply hover effects to the children when the parent is hovered
  • relative allows us to position the hover effect relative to the parent
tsx
import { useEffect, useState } from "react"
import { useMousePosition } from "./useMousePosition"
export function useHoverEffect(ref) {
const mousePosition = useMousePosition()
const [offsetX, setOffsetX] = useState(0)
const [offsetY, setOffsetY] = useState(0)
useEffect(() => {
const element = ref.current
if (!element) return
if (!mousePosition.x || !mousePosition.y) return
const rect = element.getBoundingClientRect()
setOffsetX(mousePosition.x - rect.left - rect.width / 2)
setOffsetY(mousePosition.y - rect.top - rect.height / 2)
}, [mousePosition, ref])
return function Effect() {
return (
<div
className="absolute inset-0 -z-10 translate-x-[var(--x)] translate-y-[var(--y)] rounded-lg opacity-0 transition-opacity duration-200 group-hover:bg-gray-800 group-hover:opacity-10"
style={{
"--x": `${offsetX / 8}px`,
"--y": `${offsetY / 6}px`,
}}
/>
)
}
}

The key trick here was translate-x-[var(--x)] translate-y-[var(--y)], combining Tailwind's arbitrary value support with CSS variables to set the hover effect's position.

The --x and --y variables are set to a fraction of the mouse's relative position to the button, giving the effect a nice parallax effect.

  import React from "react";
  import { Button } from './button'
  
  export default function App() {
    return (
      <div className="flex flex-col text-gray-700 max-w-xs px-4 py-2">
        <h1 className="font-bold mb-2"> Navigation </h1>
  
        <Button>🏡 Home</Button>
        <Button>📖 Blog</Button>
        <Button>🐥 Twitter</Button>
      </div>
    )
  }
Professional headshot
Moulton
Moulton

Hey there! I'm a developer, designer, and digital nomad building cool things with Remix, and I'm also writing Moulton, the Remix Community Newsletter

About once per month, I send an email with:

  • New guides and tutorials
  • Upcoming talks, meetups, and events
  • Cool new libraries and packages
  • What's new in the latest versions of Remix

Stay up to date with everything in the Remix community by entering your email below.

Unsubscribe at any time.