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;
`;
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}
`;
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}
`;
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>
);
};
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>
);
};
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;
[Footnote ends.]
Also, whether the user has scrolled or not can be retrieved as a scroll
event, with:
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).
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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)