Fixed header: Part 1
Fixed header: Part 1

Watch everything for $29/month.

Join Build UI Pro

Fixed header: Part 1

Use scroll-based animation to smoothly grow and shrink a fixed header.

Video resources

Summary

To start building our header, let's give it a fixed position and a full-screen width:

<header className='fixed inset-x-0'>
  {/*  */}
</header>

This will give us a great starting point for adding our scroll-based animation.

Scroll-based animation

Whenever we want to animate an element on scroll with Framer Motion, we use the useScroll() hook:

import { useScroll } from "framer-motion";

export default function Header() {
  let { scrollY } = useScroll();

  console.log(scrollY);
}

useScroll returns several handy properties, one of which is scrollY – the current vertical scroll position of the document.

These properties are a special type of object called a MotionValue. If we log it to the console, we'll see an object with a lot of properties whenever React renders – but as we scroll, we won't see any updates. This is because MotionValues are updated outside of React's render cycle.

To see the current value of a MotionValue, we can use its onChange callback within an effect:

useEffect(() => {
  return scrollY.onChange((current) => {
    console.log(current);
  });
}, [scrollY]);

Now as we scroll, we'll see Framer Motion updating scrollY to the current vertical scroll position of the document. Pretty cool!

To actually use this MotionValue in our JSX, all we need is a motion element, and then we can set any CSS property via its style prop to our MotionValue.

Let's turn our header into a motion.header and set its height to our new MotionValue:

import { useScroll, motion } from "framer-motion";

export default function Header() {
  let { scrollY } = useScroll();

  return (
    <motion.header style={{ height: scrollY }}>{/* ... */}</motion.header>
  );
}

Now whenever we scroll, the header's height updates to match the scroll position!

useTransform

Our header's height is tracking the scroll position, but we need to perform some additional calculations in order to get the height we want.

Because MotionValues don't update during render, we can't perform calculations on them directly in our JSX:

// 🔴 Doesn't work – MotionValues render outside of React
<motion.header style={{ height: scrollY * 0.1 }} />

Instead, we can use the useTransform hook to manipulate MotionValues before setting them on CSS properties:

import { useScroll, useTransform, motion } from "framer-motion";

export default function Header() {
  let { scrollY } = useScroll();

  // 🟢 useTransform lets us derive new MotionValues from other ones
  let height = useTransform(scrollY, (value) => Math.max(80 - value * 0.1, 50));

  return <motion.header style={{ height }}>{/*  */}</motion.header>;
}

Now our header shrinks from 80 pixels to 50 pixels as we scroll the page, and then grows back to 80 once we scroll to the top. Not bad for 3 lines of code!

Deriving the change in scroll position

This is a good start, but we're after a slightly different effect.

Currently the user has to scroll all the way back to the top of the page before the header grows to 80 pixels again. But we want the header to grow as soon as the user starts scrolling up; that is, as soon as they change their scroll direction.

Whenever we need to get the change in a variable over time, we need to know its previous value and its current value. Then, we can subtract the two to get a diff.

So, how do we get the previous and current values of a MotionValue? Let's bring back our effect:

useEffect(() => {
  return scrollY.onChange((current) => {
    console.log(current);
  });
}, [scrollY]);

We already have the current value of scrollY from the first parameter of onChange. It turns out that MotionValues are stateful, and provide us with a handy getPrevious() method to get the previous frame's value:

useEffect(() => {
  return scrollY.onChange((current) => {
    let previous = scrollY.getPrevious()
    console.log({previous, current});
  });
}, [scrollY]);

Now we have everything we need to calculate the diff!

useEffect(() => {
  return scrollY.onChange((current) => {
    let previous = scrollY.getPrevious()
    let diff = current - previous

    console.log(diff);
  });
}, [scrollY]);

When the diff is positive, the user is scrolling down, and when the diff is negative the user is scrolling up. The diff also tells us exactly how many pixels the user has scrolled by in each frame.

Custom MotionValues

Here's our current component:

export default function Header() {
  let { scrollY } = useScroll();
  let height = useTransform(scrollY, (value) =>
    Math.max(80 - value * 0.1, 50)
  );

  useEffect(() => {
    return scrollY.onChange((current) => {
      let previous = scrollY.getPrevious();
      let diff = current - previous;

      console.log(diff);
    });
  }, [scrollY]);

  return <motion.header style={{ height }}>{/* */}</motion.header>;
}

Right now, height is defined during render, but our diff is calculated within an effect.

In order to update the height from the effect, we can use the useMotionValue hook to create a custom MotionValue that we can manipulate ourselves.

import { useMotionValue, useScroll, motion } from 'framer-motion'

export default function Header() {
  let { scrollY } = useScroll();

  // Like useState, we can set an initial value
  let height = useMotionValue(80)

  useEffect(() => {
    return scrollY.onChange((current) => {
      let previous = scrollY.getPrevious();
      let diff = current - previous;

      console.log(diff);
    });
  }, [scrollY]);

  return <motion.header style={{ height }}>{/* */}</motion.header>;
}

MotionValues have a .set() method that lets us update its value, and a .get() method we can use to retrieve its current value. We can use these within our effect to update the height in response to changes in scroll direction.

Let's start by taking the case where the user has scrolled down, which we know has happened whenever the diff is positive:

useEffect(() => {
  return scrollY.onChange((current) => {
    let previous = scrollY.getPrevious();
    let diff = current - previous;

    // When the user scrolls down...
    if (diff > 0) {
      // ..shrink the header by the amount of pixels scrolled
      height.set(height.get() - diff);
    }
  });
}, [height, scrollY]);

Now the header shrinks whenever we scroll the page down!

We can clamp it to a minimum of 50 pixels like before:

height.set(Math.max(height.get() - diff, 50));

To grow the header back whenever we scroll up, we'll handle the opposite case:

useEffect(() => {
  return scrollY.onChange((current) => {
    let previous = scrollY.getPrevious();
    let diff = current - previous;

    if (diff > 0) {
      height.set(Math.max(height.get() - diff, 50));
    } else {
      // Sice the diff is negative, subtracting it increases the height
      height.set(height.get() - diff);
    }
  });
}, [height, scrollY]);

And we'll also clamp it to a max of 80:

height.set(Math.min(height.get() - diff, 80));

Let's take a look at what we wrote:

let previous = scrollY.getPrevious();
let diff = current - previous;

if (diff > 0) {
  height.set(Math.max(height.get() - diff, 50));
} else {
  height.set(Math.min(height.get() - diff, 80));
}

We can see both branches of the conditional are setting height to height.get() - diff, and one is clamping the minimum to 50 and the other the maximum to 80. So, we can simplify this by getting rid of the conditional:

let previous = scrollY.getPrevious();
let diff = current - previous;
let newHeight = height.get() - diff;

height.set(Math.min(Math.max(height.get() - diff, 50), 80));

And now we have our final effect! The header starts at a height of 80 pixels, shrinks to 50 as the user scrolls down, and whenever they start scrolling up, we start growing the header back until it hits 80 pixels again.

In the next lesson, we'll extract a reusable hook to help us implement the remaining header effects: fading out the nav items and blurring out the background.

Links

  • https://www.framer.com/docs/motionvalue

Join Build UI Pro to access this video's summary and source code.

Join Build UI Pro

Watch every video, support our work, and get exclusive perks!

Build UI is the new home for all our ideas. It will eventually have hundreds of premium videos and a thriving community, but right now it's the early days.

If you like what you see and you've ever wanted to support our work, subscribe today and start enjoying all the perks of becoming a member!

$29/month

Watch everything. Cancel anytime.

What you'll get as a Build UI Pro member

Full access to all Build UI videos

Get full access to all of our premium video content, updated monthly.

Private Discord

Ask questions and get answers from Sam, Ryan and other pro members.

Video summaries with code snippets

Easily reference videos with text summaries and copyable code snippets.

Source code

View the source code for every video, right on GitHub.

Invoices and receipts

Get reimbursed from your employer for becoming a better coder!