Fixed header: Part 2
Fixed header: Part 2

Watch everything for $29/month.

Join Build UI Pro

Fixed header: Part 2

Add a fading nav and a blurred background effect by extracting a reusable Hook.

Video resources

Summary

The naive way to fade out our nav on scroll would be to copy and paste our height code into a new opacity variable:

export default function Header() {
  let { scrollY } = useScroll();
  let height = useMotionValue(80);
  let opacity = useMotionValue(0);

  useEffect(() => {
    return scrollY.onChange((current) => {
      let previous = scrollY.getPrevious();
      let diff = current - previous;
      let newHeight = height.get() - diff;
      let newOpacity = opacity.get() - diff * 0.05;

      height.set(Math.min(Math.max(newHeight, 50), 80));
      opacity.set(Math.min(Math.max(newOpacity, 0), 1));
    });
  }, [height, scrollY, opacity]);

  return (
    <header>
      {/* ... */}
      <motion.nav style={{ opacity }} />
    </header>
  );
}

This works, but if we look closely we can see that there's an abstraction buried in our effect that applies to both our height and opacity Motion Values.

Currently, we're using Framer Motion's useScroll() to calculate the difference in scrolled pixels per frame, and then deriving the height and opacity values from that diff. But we're also clamping those values to a certain bounds so that they stop at a max and min.

What if we were to create our own Hook similar to useScroll(), but that had the clamped bounds embedded within it?

let { scrollYBounded } = useBoundedScroll(50);

If this value tracked the scroll but stopped at the bounds we passed in (50 pixels in this example), then we could write our height and opacity as simple transforms of that value.

Let's implement this hook.

Extracting a Hook

We'll start by moving some of our current logic into a new function:

function useBoundedScroll(bounds) {
  let { scrollY } = useScroll();
  let height = useMotionValue(80);

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

      height.set(Math.min(Math.max(newHeight, 50), 80));
    });
  }, [height, scrollY, opacity]);
}

We want to return a new scrollYBounded variable, so let's replace our custom height Motion Value with that, and return it from our Hook:

function useBoundedScroll(bounds) {
  let { scrollY } = useScroll();
  let scrollYBounded = useMotionValue(0);

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

      scrollYBounded.set(Math.min(Math.max(newScrollYBounded, 0), bounds));
    });
  }, [scrollY, scrollYBounded]);

  return { scrollYBounded };
}

We're defaulting scrollYBounded to 0 and clamping it to a max of the bounds arg that gets passed in.

Since we want scrollYBounded to go from 0 up to the bounds, let's add the diff instead of subtracting it:

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

      scrollYBounded.set(Math.min(Math.max(newScrollYBounded, 0), bounds));
    });
  }, [scrollY, scrollYBounded]);

Here's our first pass at our custom Hook:

function useBoundedScroll(bounds) {
  let { scrollY } = useScroll();
  let scrollYBounded = useMotionValue(0);

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

      scrollYBounded.set(Math.min(Math.max(newScrollYBounded, 0), bounds));
    });
  }, [scrollY, scrollYBounded]);

  return { scrollYBounded };
}

Let's use it in our component and see what values it logs:

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

  useEffect(() => {
    return scrollYBounded.onChange((current) => {
      console.log(current);
    });
  });
}

As we scroll down the page, we see the value goes from 0 to 50, and then stops. And as soon as we start scrolling up, the value starts decreasing again until it hits 0, where it stops.

Voilà! Let's use it.

Transforming the bounded scroll position

Now that we've encapsulated the clamped bounds inside of our new Hook, we can define our height variable as a transform of that.

In the last lesson we saw how useTransform can take a function:

let { scrollYBounded } = useBoundedScroll(50);

let height = useTransform(scrollYBounded, (v) => v);

but useTransform has another syntax, where instead of passing a function, we can pass a domain (input) and a range (output):

let height = useTransform(
  scrollYBounded,
  [domainStart, domainEnd],
  [rangeStart, rangeEnd]
);

We know scrollYBounded goes from 0 to 50, and we want our height to go from 80 down to 50, so we can write it like this:

let { scrollYBounded } = useBoundedScroll(50);

let height = useTransform(scrollYBounded, [0, 50], [80, 50]);

And just like that, we have our header effect back where it shrinks down to 50 pixels, and then starts growing to 80 pixels as soon as we change scroll direction!

Let's do the same for opacity. It should go from 1 (100% opacity) to 0 (0% opacity):

let { scrollYBounded } = useBoundedScroll(50);

let height = useTransform(scrollYBounded, [0, 50], [80, 50]);
let opacity = useTransform(scrollYBounded, [0, 50], [1, 0]);

Now both our height and opacity Motion Values are perfectly in sync with our hook! And we can change the bounds from 50 to 200 just by changing these numbers:

let { scrollYBounded } = useBoundedScroll(200);

let height = useTransform(scrollYBounded, [0, 200], [80, 50]);
let opacity = useTransform(scrollYBounded, [0, 200], [1, 0]);

Everything is perfectly synchronized and smooth.

Deriving the bounded scroll's progress

What we have works, but conceptually there is a small unnecessary coupling between our transformed values and our Hook: the values have to know that scrollYBounded goes from 0 to 200. In reality, they only care about the percentage that value is of its max – in other words, its progress.

If we take a look at the return values from Framer Motion's own useScroll for some API inspiration, we can see it returns the progress of scrollY alongside its absolute position:

let { scrollY, scrollYProgress } = useScroll();

We can do the same for our hook and return a scrollYBoundedProgress that goes from 0 to 1, and would remove the coupling from our derived values.

How might we calculate scrollYBoundedProgress and return it from our Hook? We can use another transform:

function useBoundedScroll(bounds) {
  let { scrollY } = useScroll();
  let scrollYBounded = useMotionValue(0);
  let scrollYBoundedProgress = useTransform(
    scrollYBounded,
    [0, bounds],
    [0, 1]
  );

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

      scrollYBounded.set(Math.min(Math.max(newScrollYBounded, 0), bounds));
    });
  }, [scrollY, scrollYBounded]);

  return { scrollYBounded, scrollYBoundedProgress };
}

We're mapping scrollYBounded which goes from 0 to bounds, into a number that smoothly goes from 0 to 1, and then returning it.

Now we can rewrite our height and opacity transforms using the progress:

let { scrollYBoundedProgress } = useBoundedScroll(200);

let height = useTransform(scrollYBoundedProgress, [0, 1], [80, 50]);
let opacity = useTransform(scrollYBoundedProgress, [0, 1], [1, 0]);

Since the progress always goes from 0 to 1, we can now change our bounds in a single place, and every effect is updated appropriately.

This example demonstrates how elegantly React Hooks compose together. Learning how to design Hooks that get their boundaries right will give rise to powerful new abstractions in your own applications.

Scaling the logo and blurring the background

Now that we have our Hook just the way we want it, let's see how easy it is to pull off a few more effects.

First, let's scale the logo. We'll turn its p tag into a motion.p and set the scale style property directly inline:

<motion.p
  style={{
    scale: useTransform(scrollYBoundedProgressThrottled, [0, 1], [1, 0.9]),
  }}
>
  The Daily Bugle
</motion.p>

That's all it takes! The logo scales from 100% to 90% as we scroll through our bounds.

Finally, we want to blur the header's background. To achieve a blurred background effect, we need to do two things: reduce the opacity of the header's background color, and set the backdrop-filter CSS property to blur(12px), which we can easily do by adding the backdrop-blur-md Tailwind class:

<motion.header className="bg-white/10 backdrop-blur-md" />

The bg-white/10 Tailwind class sets the CSS property background-color to a value of rgb(255 255 255 / 0.10). If we animate this opacity between 1 and 0.10, we can achieve our effect. As we know from the previous lesson, we can't animate CSS classes, so let's set the background color using an inline style:

<motion.header
  className="backdrop-blur-md"
  style={{ backgroundColor: "rgb(255 255 255 / 0.1" }}
/>

It would be nice if we could inline our transform directly:

<motion.header
  className="backdrop-blur-md"
  style={{
    backgroundColor: `rgb(255 255 255 / ${useTransform(
      scrollYBoundedProgress,
      [0, 1],
      [1, 0.1]
    )}`,
  }}
/>

but as we learned, we can't use Motion Values directly in React's render function like this. Fortunately, Framer Motion has our back with the truly awesome useMotionTemplate Hook:

<motion.header
  className="backdrop-blur-md"
  style={{
    backgroundColor: useMotionTemplate`rgb(255 255 255 / ${useTransform(
      scrollYBoundedProgress,
      [0, 1],
      [1, 0.1]
    )}`,
  }}
/>

useMotionTemplate takes in a tagged template and processes the string for us. All we need to do is wrap any CSS value that's a string with it, and Framer Motion will interpolate any Motion Value we pass in. Incredible!

Our background now blurs on scroll, and all four header effects are smoothly in sync with each other. We can now tweak the entire animation just by changing the bounds we pass in to useBoundedScroll.

Delaying the effect

One final touch: What if we wanted to delay the animation, so the effects only start after we've scrolled a bit through our bounds?

Let's use another transform to create a throttled version of our bounded scroll progress:

let { scrollYBoundedProgress } = useBoundedScroll(200);
let scrollYBoundedProgressThrottled = useTransform(
  scrollYBoundedProgress,
  [0, 0.5, 1],
  [0, 0, 1]
);

Here we're seeing that we can actually pass any number of steps into the domain and range of useTransform. This creates a piecewise function:

  • When scrollYBoundedProgress goes from 0% to 50%, scrollYBoundedProgress stays at 0%
  • When scrollYBoundedProgress goes from 50% to 100%, scrollYBoundedProgress kicks in and starts going from 0% to 100%

And now, with our bounds set to 200, nothing happens for the first 100 pixels, and then our animation begins as we scroll from 100 pixels to 200 pixels.

Once again, we see how easy Framer Motion and React Hooks make it to achieve our desired results!

Links

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!