Skip to main content

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>
  );
}

View Codesandbox

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>
  );
}

View Codesandbox

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>
  );
}

View Codesandbox