Implementing Headroom.js with Framer Motion
Headroom.js was a popular library for animating your header element in and out of the viewport based on scroll direction.
In some recent work exploration I wanted to see how we could recreate the same type of functionality with Framer Motion.
Turns out the basic functionality can be recreated quite easily with the useScroll
hook. We can get the previous state of the scrollY
motion value via the getPrevious
method, and use that to compare against the latest value and animate between the variants.
import { motion, useScroll } from "framer-motion";
// Create our variant states to switch between
const variants = {
unpinned: {
y: "-100%",
},
pinned: {
y: 0,
},
};
export default function Nav() {
const [variant, setVariant] = React.useState("pinned");
const { scrollY } = useScroll();
React.useEffect(() => {
scrollY.onChange((latest) => {
const previous = scrollY.getPrevious();
if (latest > previous) {
// Scrolling down
setVariant("unpinned");
} else {
// Scrolling up
setVariant("pinned");
}
});
}, [scrollY]);
return (
<motion.nav
initial="pinned"
animate={variant}
variants={variants}
transition={{
bounce: 0,
}}
>
...
</motion.nav>
);
}
import { motion, useScroll } from "framer-motion";
// Create our variant states to switch between
const variants = {
unpinned: {
y: "-100%",
},
pinned: {
y: 0,
},
};
export default function Nav() {
const [variant, setVariant] = React.useState("pinned");
const { scrollY } = useScroll();
React.useEffect(() => {
scrollY.onChange((latest) => {
const previous = scrollY.getPrevious();
if (latest > previous) {
// Scrolling down
setVariant("unpinned");
} else {
// Scrolling up
setVariant("pinned");
}
});
}, [scrollY]);
return (
<motion.nav
initial="pinned"
animate={variant}
variants={variants}
transition={{
bounce: 0,
}}
>
...
</motion.nav>
);
}
Now that we have the ability to animate between pinned
and unpinned
states based on our scroll direction, let's add the offset and tolerance options to our setup.
Offset
The vertical offset in px before element is first unpinned.
export default function Nav() {
const [variant, setVariant] = React.useState("pinned");
const { scrollY } = useScroll();
React.useEffect(() => {
scrollY.onChange((latest) => {
const previous = scrollY.getPrevious();
const currentScrolledPixels = scrollY.get();
// If we have yet to scroll 80 pixels, return early
if (currentScrolledPixels < 80) {
return;
}
if (latest > previous) {
setVariant("unpinned");
} else {
setVariant("pinned");
}
});
}, [scrollY]);
return (
<motion.nav
initial="pinned"
animate={variant}
variants={variants}
transition={{
bounce: 0,
}}
>
...
</motion.nav>
);
}
export default function Nav() {
const [variant, setVariant] = React.useState("pinned");
const { scrollY } = useScroll();
React.useEffect(() => {
scrollY.onChange((latest) => {
const previous = scrollY.getPrevious();
const currentScrolledPixels = scrollY.get();
// If we have yet to scroll 80 pixels, return early
if (currentScrolledPixels < 80) {
return;
}
if (latest > previous) {
setVariant("unpinned");
} else {
setVariant("pinned");
}
});
}, [scrollY]);
return (
<motion.nav
initial="pinned"
animate={variant}
variants={variants}
transition={{
bounce: 0,
}}
>
...
</motion.nav>
);
}
Tolerance
The scroll tolerance in px before state changes.
const inRange = (num, rangeStart, rangeEnd = 0) =>
(rangeStart < num && num < rangeEnd) || (rangeEnd < num && num < rangeStart);
export default function Nav() {
const [variant, setVariant] = React.useState("pinned");
const { scrollY } = useScroll();
React.useEffect(() => {
scrollY.onChange((latest) => {
const previous = scrollY.getPrevious();
const diff = latest - previous;
const currentScrolledPixels = scrollY.get();
// If we have yet to scroll 80 pixels or we've
// not scrolled more than 20px, return early
if (currentScrolledPixels < 80 || inRange(diff, -20, 20)) {
return;
}
if (latest > previous) {
setVariant("unpinned");
} else {
setVariant("pinned");
}
});
}, [scrollY]);
return (
<motion.nav
initial="pinned"
animate={variant}
variants={variants}
transition={{
bounce: 0,
}}
>
...
</motion.nav>
);
}
const inRange = (num, rangeStart, rangeEnd = 0) =>
(rangeStart < num && num < rangeEnd) || (rangeEnd < num && num < rangeStart);
export default function Nav() {
const [variant, setVariant] = React.useState("pinned");
const { scrollY } = useScroll();
React.useEffect(() => {
scrollY.onChange((latest) => {
const previous = scrollY.getPrevious();
const diff = latest - previous;
const currentScrolledPixels = scrollY.get();
// If we have yet to scroll 80 pixels or we've
// not scrolled more than 20px, return early
if (currentScrolledPixels < 80 || inRange(diff, -20, 20)) {
return;
}
if (latest > previous) {
setVariant("unpinned");
} else {
setVariant("pinned");
}
});
}, [scrollY]);
return (
<motion.nav
initial="pinned"
animate={variant}
variants={variants}
transition={{
bounce: 0,
}}
>
...
</motion.nav>
);
}