The Perfect Header Animation
Jan 29th, 2023β’Updated Mar 9th, 2023
Share on TwitterShare on LinkedInShare on RedditShare on FacebookWhat do you enjoy about being deep in a facinating article? The content? Probably. The author? Maybe. Not being distracted while reading? Most certainly.
One thing that frequently gets in the way for me is when the website hosting the article has a header that hides or shows at even the slightest scroll. dropbox.tech, one of my favorite tech blogs, unfortunately does this.
Personally, when I'm reading, I tend to do so on mobile. Because my thumb is scrolling while I'm processing the lines, thus covering the bottom half of lines, I keep my focused line at the top of the page. When all I'm doing is scrolling down, everything is fine, but if I so much as scroll up by a single pixel, the header crashes in often covering the line I'm currently trying to read. Annoying π€.
Medium however understands me π.
When it came time to build the header for my website. I knew, at least for the sake of my sanity, that I had to implement the animation I loved so much.
In this post, I'll take you through the journey of how I implemented this, why Medium's solution didn't work for me, and how I found the perfect solution in an unexpected place π€.
Recreating Medium's header
Let's start by trying to reverse engineer what Medium is doing.
It might be hard to see, but what medium is doing is first setting position: sticky;
and top: 0;
in order to get the header to be anchored to the top. Then in javascript they use css's transform property to move the header in and out out of view as the user scrolls. Also note that the value is only ever between 0
and -98px
, the negative value of the header's height. More on this later.
Alright, simple enough. Let's get started on an implementation. Here is the component.
import { useEffect, useRef } from 'react'
export const FirstAttempt: React.FC = () => {
return <header>Header content!</header>
}
And some css to make it pretty. Notice the last two lines as being the important lines to make this work.
header {
display: flex;
padding: 1rem;
background: green;
color: white;
/* these are the styles that we need to make the hiding/showing work */
position: sticky;
top: 0;
}
This is what we get to start with.
Now let's begin adding in that fancy animation. We are first going to need to add a handler to listen on scroll events.
import { useEffect, useRef } from 'react'
export const FirstAttemptHeader: React.FC = () => {
useEffect(() => {
const handler = () => {
// TODO: add logic
}
window.addEventListener('scroll', handler)
return () => window.removeEventListener('scroll', handler)
}, [])
return <header>Header content!</header>
}
We wrap it in a useEffect
with an empty dependency array because we only want this to run once. We return a cleanup function () => window.removeEventListener('scroll', handler)
that unregisters this handler when this component is unmounted. See the react docs for more on how this works.
Because we will need to inline the translationY
css property, we need a reference to the node we want to show and hide.
import { useEffect, useRef } from 'react'
export const FirstAttemptHeader: React.FC = () => {
const nodeRef = useRef<HTMLElement>(null!)
useEffect(() => {
let previousY: number | undefined = undefined
let translationY = 0
const handler = () => {
// TODO: logic
}
window.addEventListener('scroll', handler)
return () => window.removeEventListener('scroll', handler)
}, [])
return <header ref={nodeRef}>Header content!</header>
}
Now let's add the logic for what should happen on a scroll event. One way to think of the logic is like this:
- Calculate the change in scroll position
- Add that diff to the header's current
translationY
to get the newtranslationY
, but bound it between0
and the negative value of the header's height (also known as "clamping" the value between the two boundaries) - Set the header's inline style to the newly calculated
translationY
Number 2 might take some explaining. If you look at the translationY
value set on the header in the case of Medium's header (see below), you see that it follows this same logic. The translationY
will never be more than 0
(positive values push the element down), and never be less than -98px
(negative values push the element up) which just so happens to be the header element's height (you can see this by inspecting the element in the browser's dev tools).
Let's add that logic in.
import { useEffect, useRef } from 'react'
export const FirstAttemptHeader: React.FC = () => {
const nodeRef = useRef<HTMLElement>(null!)
useEffect(() => {
let previousY: number | undefined = undefined
let translationY = 0
const handler = () => {
const currentY = window.scrollY
if (previousY === undefined) {
previousY = currentY
return
}
const diff = currentY - previousY
previousY = currentY
const { height } = nodeRef.current.getBoundingClientRect()
translationY = Math.min(Math.max(translationY - diff, -height), 0)
nodeRef.current.style.transform = `translateY(${translationY}px)`
}
window.addEventListener('scroll', handler)
return () => window.removeEventListener('scroll', handler)
}, [])
return <header ref={nodeRef}>Header content!</header>
}
Success!
The problem
Simple solutions look great until they meet the requirements of the real world. Such was the case when I tried to implement this on my site.
What I want my header to also support, is on smaller screens, the following items.
- I want to hide the navigation behind a "Menu" button that brings up a popover with all of the nav items
- I want to use headlessui's popover component for this
- I want to use fancy backdrop blurs
- I want it to remain in the same place if the user scrolls.
Over all, it would look like this.
Instead, this happens:
Why is this? Honestly, I'm having a hard time figuring this one out myself, but here is my current theory. Because the overlay wants to be absolutely positioned so it can be anchored to the viewport on scroll, it clashes with position: sticky;
on the header seems to ruin this because the overlay becomes relative to it instead of the viewport. Therefore, when it hides by being pushed up, the overlay gets pushed up too. What doesn't make sense is that the overlay doesn't seem to move up by the same amount, so therefore I feel like this explanation isn't completely accurate π€·π»ββοΈ.
Also you may have noticed that the fancy blurred backdrop that I wanted doesn't work any more. It applies only to the header. I'm not exactly sure why, but it seems that the blurred background gets messed up with position: sticky;
on the header.
To solve these problems, I could keep the overlay and related open/close logic outside of the header, but this didn't work well with the popover component.
Getting around the background problem was simple. Only blur the background when the popover is closed. For the positioning problem, however, honestly, I was at loss for where to go. How could I get the header animation I wanted, but also support the popover for mobile devices. I was about ready to give up trying to have my cake and eat it too.
Que tailwindui.com.
The perfect solution
tailwindui.com is made by the folks who bring us tailwind. It is a way for them to make some income by selling website templates. I follow their newsletter since their content is top tier ππ» and found that they released a website template for a personal website. Of course I had to check it out when I went to build my own personal website! To my chagrin, they use the EXACT header that I wanted!
Naturally I popped open Chrome's dev tools in attempt to pick apart how they were doing it and honestly it didn't make much sense to me. curiosity was killing me so I paid for the template myself. Honestly they deserve the money in my opinion.
Their solution is much more complex since it handles several other features, but the part that applies to what I needed ended up being.....somewhat.....simple. It uses a technique where the content in the header that you want to show and hide is wrapped in a div. That div gets the position: sticky;
and top: 0;
properties whereas the header itself has expandable height with a negative margin to off set the content below it.
If this sounds crazy to you, then yes, you're right, but it works π€·π». The code is complicated so I'll have to save an explanation for a different post if y'all desire me to go into it. If you want to know how I implemented it, you can just look at the source code for my header and the accompanying hook that encapsulates this logic.
Conclusion
Getting simple things to work in css can be tiring, but you learn so much on the way. Often, it takes diving deep into code you didn't write and trying until you have that eureka moment. Recently, for me, this described the process of creating the header you now see on this website. I hope you enjoy it π.
That's all for now. Bye! ππ»
You can also support me and my tea addition π€π΅.
Or share with othersShare on TwitterShare on LinkedInShare on RedditShare on Facebook