DEV Community

Cover image for React Hooks to scroll-animate a top app bar in Material Design style
Masa Kudamatsu
Masa Kudamatsu

Posted on • Updated on

React Hooks to scroll-animate a top app bar in Material Design style

Disclaimer: This article is currently a "beta version" (1.0.0-beta.6), by which I mean the quality of exposition doesn't meet my own expectation yet. But I tend to keep such "beta version" articles in my MacBook forever. To publish the core idea sooner rather than never, I ask you to bear with my imperfect writing below.

Problem

Today I've managed to scratch-build Material Design's top app bar, in particular its behaviour of sliding up/down if the user scrolls down/up, without relying on libraries like Material Components Web or Material-UI.

The starting point was a StackOverflow answer by user8808265 (2018), but his/her snippet uses React's now-outdated class components.

So I needed to update his/her snippet into the one with React Hooks. In addition, I wanted to use my favorite Styled Components to apply CSS.

Here's what I have come up with:

Setting CSS with Styled Components

First, create a Styled Component with CSS transitions to apply. I call it Header with semantic HTML tag <header>

// Header.js

import styled from 'styled-components';

export default const Header = styled.header`
  position: fixed;
  width: 100%;
  z-index: 1;
`;
Enter fullscreen mode Exit fullscreen mode

For how the styled function works, see here.

The three CSS declarations are the standard ones for a top app bar. With width:100%, the app bar spreads across the screen. The combination of position:fixed and z-index:1 makes the app bar stays above the content that slides in beneath when the user scrolls up.

Now I add CSS transitions as props:

// Header.js

import styled from 'styled-components';

// ADDED
const animation = {
    hide: ``,
    show: ``,
} 

export default const Header = styled.header`
  position: fixed;
  width: 100%;
  z-index: 1;

  /* ADDED */
  ${props => props.hide && animation.hide} 
  ${props => props.show && animation.show}
`;
Enter fullscreen mode Exit fullscreen mode

For how props work in Styled Components, see here.

When the hide prop is true, then CSS declarations stored in animation.hide will apply to Header. This is for when the user scrolls down.

When the show prop is true, then CSS declarations stored in animation.show will apply to Header. This is for when the user scrolls up.

Then add CSS declarations for animation:

// Header.js

import styled from 'styled-components';

// ADDED
const topAppBarHeight = 70; 

const animation = {
    hide: `
      /* ADDED */
      transform: translate(0, -${topAppBarHeight}px);
      transition: transform .5s;
    `,
    show: `
      /* ADDED */
      transform: translate(0, 0);
      transition: transform .25s;
    `,
} 

export default const Header = styled.header`
  /* ADDED */
  height: ${topAppBarHeight}px; 

  position: fixed;
  width: 100%;
  z-index: 1;
  ${props => props.hide && animation.hide} 
  ${props => props.show && animation.show} 
`;
Enter fullscreen mode Exit fullscreen mode

To hide the app bar, we need to move it upwards by its height. The height value is therefore needed to explicitly set. So define it as topAppBarHeight and refer to it both in the transform property and height property.

To show the app bar, translate it back to its original position with transform:translate(0,0).

Ideally, the animation speed should sync with the speed of scrolling. But I haven't figure out how (that'll be worth another article). So here I set 0.5 second for sliding up and 0.25 second for sliding down (for simplicity).

In my view the speed of sliding down (i.e. reappearing with scrolling up) should be faster than that of sliding up to disappear. We don't care about what is going out, but we do care about what is coming in. An element that reappears should reappear quick.

That's all for CSS.

JavaScript with React Hooks

We now want to apply hide prop to Header component when the user scrolls down; and apply show prop when the user scrolls up.

To start with, create the TopAppBar component out of the Header styled component:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {
  return (
    <Header>
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

The useState hook

Now we want to manage whether to show the top app bar as a boolean state variable called show. And apply the show or hide prop to Header by the value of show:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {

  // ADDED
  const [show, setShow] = React.useState(true); 

  return (
    {/* REVISED */}
    <Header show={show} hide={!show}> 
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

By default, the show state is true. So the Header component receives the show prop to show the top app bar at the top of the screen.

When we programatically change show into false, then the hide prop, instead of show, will be given to Header so that the top app bar will disappear by sliding up.

When we programatically change show back into true, then the show prop, instead of hide, will be given to Header so that the top app bar will reappear by sliding up.

The window object

Now, how can we code to connect the scroll events to the boolean value of the show state? We need the window object.

How much the user has scrolled the page can be obtained with window.scrollY. According to MDN Contributors (2021a), "scrollY returns the Y coordinate of the top edge of the current viewport".


[Footnote added on 30 Jan, 2021] Note: window.scrollY does not support IE at all (Can I Use 2021a). If you need to support up to IE 9, use window.pageYOffset instead (see Can I Use 2021b and MDN 2021b). If you need to support IE 6-8, use either document.documentElement.scrollTop or document.body.scrollTop. Element API scrollTop supports up to IE 6 (Can I Use 2021c). But Chrome always returns 0 for document.body.scrollTop while Safari always returns 0 for document.documentElement.scrollTop. To use the valid one of the two, MDN Contributors (2021a) suggests the following feature detection code:

var supportPageOffset = window.pageXOffset !== undefined;
var isCSS1Compat = ((document.compatMode || "") === "CSS1Compat");
var x = supportPageOffset ? window.pageXOffset : isCSS1Compat ? document.documentElement.scrollLeft : document.body.scrollLeft;
var y = supportPageOffset ? window.pageYOffset : isCSS1Compat ? document.documentElement.scrollTop : document.body.scrollTop;
Enter fullscreen mode Exit fullscreen mode

[Footnote ends.]


Also, whether the user has scrolled or not can be retrieved as a scroll event, with:

window.addEventListener('scroll', handleScroll, {passive: true});
Enter fullscreen mode Exit fullscreen mode

where handleScroll is a function to implement upon the scroll event. The {passive: true} option improves performance (see MDN Contributors 2021b).

The useEffect hook

We need the window object for both purposes. Accessing window object with React is not straightforward, however, because it becomes available only after components are rendered. Whenever we need to run a set of code after components are rendered, it's time to use the useEffect hook:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {
  const [show, setShow] = React.useState(true); 

  // ADDED
  React.useEffect(() => { 

    function handleScroll() {
      const newScrollPosition = window.scrollY;
    }

    window.addEventListener('scroll', handleScroll, {passive: true});
  }, []);

  return (
    <Header show={show} hide={!show}>
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

The code inside the useEffect hook first defines the handleScroll function in which we store how much the user has scrolled as newScrollPosition (more to be added). Then this function gets attached to the scroll event with window.addEventListener().

Once the handleScroll function is set as a scroll event handler, the rest will be automatic. So we need to run this useEffect only once when TopAppBar gets mounted. That's why we add [] as the second argument of useEffect.

I've learned this technique of using useEffect to access to the window object from a Dev.to artcile by Maldur (2020), which discusses how to access the current browser window width from the window resize event.

Keeping track of scroll positions

We're almost done. The last thing to do is to check whether the user has scrolled down or up. We can tell this by comparing the current scroll position (window.scrollY) to the previous one. If the current one is larger, then the user has scrolled down. If it's smaller, the user has scrolled up.

So we need to keep track of the previous scroll position:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {
  const [show, setShow] = React.useState(true); 

  React.useEffect(() => { 

    // ADDED
    let scrollPosition = 0; 

    function handleScroll() {
      const newScrollPosition = window.scrollY;

      // ADDED
      scrollPosition = newScrollPosition; 
    }

    window.addEventListener('scroll', handleScroll, {passive: true});
  }, []) 

return (
    <Header show={show} hide={!show}>
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

Everytime the user scrolls, the handleScroll will be run. Each time, the new scroll position is obtained and stored as scrollPosition.

Now we want to compare the current and previous scroll positions and flip the boolean show state accordingly:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {
  const [show, setShow] = React.useState(true); 

  React.useEffect(() => { 
    let scrollPosition = 0;

    function handleScroll() {
      const newScrollPosition = window.scrollY;

      // ADDED
      const shouldShow = newScrollPosition < scrollPosition;

      // ADDED
      setShow(shouldShow);

      scrollPosition = newScrollPosition; 
    }

    window.addEventListener('scroll', handleScroll, {passive: true});
  }, []) 

return (
    <Header show={show} hide={!show}>
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

Edge-case handling

Now, just in case the scroll position does not change after a scroll event (I'm not sure how this can happen, though), let's deal with such an edge case by exiting early with return:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {
  const [show, setShow] = React.useState(true); 

  React.useEffect(() => { 
    let scrollPosition = 0;

    function handleScroll() {
      const newScrollPosition = window.scrollY;

      // ADDED
      if (newScrollPosition === scrollPosition) {
        return;
      }

      const shouldShow = newScrollPosition < scrollPosition; 

      setShow(shouldShow); 

      scrollPosition = newScrollPosition; 
    }

    window.addEventListener('scroll', handleScroll, {passive: true});
  }, []) 

return (
    <Header show={show} hide={!show}>
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

Deal with Safari

[This section is added on 30 Jan, 2021]

I find Safari behaves slightly differently from other browsers. With modern browsers (except Firefox) the page overshoots and bounces back when the user forcefully scrolls up to the top. When this happens, the window.scrollY value should remain zero for the above code to work. With Safari, however, it turns negative and, when the page bounces back, increases to zero as if the user were scrolling down.

With the above code, this will hide the top app bar. That's not what we intend.

So we have to revise the code as follows:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {
  const [show, setShow] = React.useState(true); 

  React.useEffect(() => { 
    let scrollPosition = 0;

    function handleScroll() {
      const newScrollPosition = window.scrollY;

      if (newScrollPosition === scrollPosition) {
        return;
      }

      // ADDED
      if (newScrollPosition < 0) {
        return;
      }

      const shouldShow = newScrollPosition < scrollPosition; 

      setShow(shouldShow); 

      scrollPosition = newScrollPosition; 
    }

    window.addEventListener('scroll', handleScroll, {passive: true});
  }, []) 

return (
    <Header show={show} hide={!show}>
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

The same thing happens when the user scrolls down the page to the bottom with force. The page overshoots and bounces up. Safari keeps updating window.scrollY while this overshooting happens. When the page bounces up, the new window.scrollY value is smaller than the previous one, revealing the top app bar.

This behaviour may be what you want. Otherwise, revise the code as follows:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {
  const [show, setShow] = React.useState(true); 

  React.useEffect(() => { 
    let scrollPosition = 0;

    // ADDED
    const pageHeight = document.body.offsetHeight;
    const viewportHeight = window.innerHeight;

    function handleScroll() {
      const newScrollPosition = window.scrollY;

      if (newScrollPosition === scrollPosition) {
        return;
      }

      // REVISED
      if (newScrollPosition < 0 || newScrollPosition + viewportHeight > pageHeight) {
        return;
      }

      const shouldShow = newScrollPosition < scrollPosition; 

      setShow(shouldShow); 

      scrollPosition = newScrollPosition; 
    }

    window.addEventListener('scroll', handleScroll, {passive: true});
  }, []) 

return (
    <Header show={show} hide={!show}>
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

where we first obtain the entire page height from document.body.offsetHeight and the viewport height from window.innerHeight. If the sum of window.scrollY and the viewport height exceeds the entire page height, then it means the scrolling-down page overshoots. When this happens, the above code stops updating the scroll position value.

Cleanup upon component dismounting

Finally, when the TopAppBar component gets dismounted, we want to remove the scroll event handler. This can be done by return-ing a function inside the useEffect hook:

// TopAppBar.js

import React from 'react';
import Header from './Header';

export default const TopAppBar = () => {
  const [show, setShow] = React.useState(true); 

  React.useEffect(() => { 
    let scrollPosition = 0;

    const pageHeight = document.body.offsetHeight;
    const viewportHeight = window.innerHeight;

    function handleScroll() {
      const newScrollPosition = window.scrollY;

      if (newScrollPosition === scrollPosition) {
        return;
      } 

      if (newScrollPosition < 0 || newScrollPosition + viewportHeight > pageHeight) {
        return;
      }

      const shouldShow = newScrollPosition < scrollPosition; 
      setShow(shouldShow); 

      scrollPosition = newScrollPosition; 
    }

    window.addEventListener('scroll', handleScroll, {passive: true});

    // ADDED
    return () => {
      window.removeEventListener('scroll', handleScroll);
    }; 
  }, []) 

return (
    <Header show={show} hide={!show}>
      {/* Insert the top app bar content */}
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now the top app bar should behave as Material Design specifies!

References

Maldur, Vitalie (2020) “Resize event listener using React hooks”, Dev.to, Jan. 9, 2020.

MDN Contributors (2021a) “Window.scrollY”, MDN Web Docs, Jan. 11, 2021.

MDN Contributors (2021b) “EventTarget.addEventListener()”, MDN Web Docs, Jan. 20, 2021.

user8808265 (2018) “An answer to ‘How to make AppBar component from material-ui-next react to scroll events’”, Stack Overflow, May 18. 2018.

Top comments (0)