React Hooks to scroll-animate a top app bar in Material Design style
Tags: react, styledcomponents, materialdesign
Published: 2021-01-20 12:02:18

*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](https://material.io/components/app-bars-top), in particular its behaviour of [sliding up/down if the user scrolls down/up](https://kstatic.googleusercontent.com/files/60e734664455ce42d79267bdc7f7dd20239ea2086102e302e0d8c3e255b639faddbbbeaf75ceb9ac3fb2f1f14451f937427eabaf6aa028bcd4a3bd80504e68b2), without relying on libraries like [Material Components Web](https://github.com/material-components/material-components-web) or [Material-UI](https://material-ui.com/).

The starting point was a StackOverflow answer by [user8808265 (2018)](https://stackoverflow.com/a/50427181), 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](https://styled-components.com/) 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>`
```js
// Header.js

import styled from 'styled-components';

export default const Header = styled.header`
  position: fixed;
  width: 100%;
  z-index: 1;
`;
```
For how the `styled` function works, [see here](https://styled-components.com/docs/basics#getting-started).

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`:
```js
// 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}
`;
```
For how `props` work in Styled Components, [see here](https://styled-components.com/docs/basics#adapting-based-on-props).

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:
```js
// 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} 
`;
```
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:
```js
// TopAppBar.js

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

export default const TopAppBar = () => {
  return (
    <Header>
      {/* Insert the top app bar content */}
    </Header>
  );
};
```
### 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`:
```js
// 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>
  );
};
```
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](https://developer.mozilla.org/en-US/docs/Web/API/Window).

How much the user has scrolled the page can be obtained with `window.scrollY`. According to [MDN Contributors (2021a)](https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY), "`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](https://caniuse.com/mdn-api_window_scrolly)). If you need to support up to IE 9, use `window.pageYOffset` instead (see [Can I Use 2021b](https://caniuse.com/mdn-api_window_pageyoffset) and [MDN 2021b](https://developer.mozilla.org/en-US/docs/Web/API/Window/pageYOffset)). 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](https://caniuse.com/mdn-api_element_scrolltop)). 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)](https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY#notes) suggests the following feature detection code:
```javascript
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;
```

**[Footnote ends.]**

---

Also, whether the user has scrolled or not can be retrieved as a `scroll` event, with:
```js
window.addEventListener('scroll', handleScroll, {passive: true});
```
where `handleScroll` is a function to implement upon the `scroll` event. The `{passive: true}` option improves performance (see [MDN Contributors 2021b](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners)).

### 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:
```js
// 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>
  );
};
```
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)](https://dev.to/vitaliemaldur/resize-event-listener-using-react-hooks-1k0c), 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:
```js
// 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>
  );
};
```

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:

```js
// 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>
  );
};
```

### 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`:

```js
// 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>
  );
};
```

### 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:
```javascript
// 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>
  );
};
```

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:
```javascript
// 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>
  );
};
``` 
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: 

```js
// 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>
  );
};
```
Now the top app bar should behave as Material Design specifies!


## References
Maldur, Vitalie (2020) “[Resize event listener using React hooks](https://dev.to/vitaliemaldur/resize-event-listener-using-react-hooks-1k0c)”, _Dev.to_, Jan. 9, 2020. 

MDN Contributors (2021a) “[Window.scrollY](https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY)”, _MDN Web Docs_, Jan. 11, 2021. 

MDN Contributors (2021b) “[EventTarget.addEventListener()](https://developer.mozilla.org/en-US/docs/Web/API/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’](https://stackoverflow.com/a/50427181)”, _Stack Overflow_, May 18. 2018.