Scroll Direction: Hide Header
A sticky header that hides when scrolling down and reappears when scrolling up. Uses useMotionValueEvent to detect scroll direction.
Introduction
The Scroll Direction: Hide Header example shows a sticky header that hides when scrolling down and reveals when scrolling up. This is a common UX pattern that maximizes screen space while keeping navigation accessible.
This tutorial uses three Motion APIs:
useScrollto track the scroll positionuseMotionValueEventto listen for scroll changes and detect direction- the
animateprop to smoothly hide and show the header
Motion provides a scrollY motion value that updates on every scroll event, making it easy to track scroll direction by comparing the current position to the previous one.
Get started
Let's start with a fixed header and some scrollable content.
"use client"
import { motion } from "motion/react"
export default function ScrollHideHeader() {
return (
<div id="example">
<header className="header">
<div className="header-content">
<div className="logo">Logo</div>
<nav>
<a href="#">Docs</a>
<a href="#">Examples</a>
<a href="#">Blog</a>
</nav>
</div>
</header>
<main className="content">
<section className="hero">
<p>Scroll down to hide header.<br />Scroll up to reveal header.</p>
</section>
{Array.from({ length: 6 }).map((_, i) => (
<section key={i} className="placeholder-section">
Section {i + 1}
</section>
))}
</main>
<style>{`
/** Copy styles from example source code */
`}</style>
</div>
)
}
The header is positioned with position: fixed in CSS, so it stays at the top of the viewport while content scrolls beneath it.
Let's animate!
Import from Motion
We'll need useScroll to track scroll position, useMotionValueEvent to listen for changes, and useState to track whether the header is hidden.
import { motion, useScroll, useMotionValueEvent } from "motion/react"
import { useState } from "react"
Track scroll position
useScroll returns a scrollY motion value that represents the vertical scroll position in pixels.
export default function ScrollHideHeader() {
const { scrollY } = useScroll()
const [hidden, setHidden] = useState(false)
/** ... */
}
Detect scroll direction
Now we'll use useMotionValueEvent to listen for changes to scrollY and determine the scroll direction.
export default function ScrollHideHeader() {
const { scrollY } = useScroll()
const [hidden, setHidden] = useState(false)
useMotionValueEvent(scrollY, "change", (current) => {
const previous = scrollY.getPrevious() ?? 0
if (current > previous && current > 150) {
setHidden(true)
} else {
setHidden(false)
}
})
/** ... */
}
The callback receives the current scroll position as an argument. We get the previous position by calling scrollY.getPrevious(), which returns the value from the last time scrollY updated.
The logic is straightforward: if we're scrolling down (current > previous) and we've scrolled past 150 pixels, hide the header. Otherwise, show it. The 150 pixel threshold prevents the header from hiding during small scrolls at the top of the page.
Animate the header
Finally, we'll convert the <header> to a motion.header and use the animate prop to smoothly hide and show it based on the hidden state.
<motion.header
className="header"
animate={{
y: hidden ? -140 : 0,
opacity: hidden ? 0 : 1,
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div className="header-content">
<div className="logo">Logo</div>
<nav>
<a href="#">Docs</a>
<a href="#">Examples</a>
<a href="#">Blog</a>
</nav>
</div>
</motion.header>
When hidden is true, we translate the header up by -140 pixels (moving it out of view) and fade it to 0 opacity. When hidden is false, we reset both properties to their default values. The transition prop ensures the animation is smooth rather than instant.
Conclusion
We've built a scroll-responsive header that hides when scrolling down and reveals when scrolling up. This pattern uses useScroll to track scroll position, useMotionValueEvent to detect direction changes, and the animate prop to smoothly animate the header in and out of view. The technique is efficient because motion values update without causing re-renders, and the state change only happens when the scroll direction changes.
