Let us build a headroom-style header in Svelte! Our objective in this blog post is to create a header that slides up (and out-of-view) when the user scrolls down, and re-appears when they scroll up (no matter how far down the page they are).
This is a technique used to save room on the screen while sparing the user from having to scroll all the way back up the page to get to the header and navigation.
We won't use the popular headroom.js but roll up a our own simple solution while honing our Svelte skills along the way. Are you ready?
The Layout
We'll begin with a component that has a fixed
header as if it was already "pinned". Let's give our header a height
and background-color
so we can actually see it. Our Svelte component sees the light of day:
<style>
header {
background-color: darkgrey;
height: 80px;
position: fixed;
width: 100%;
}
main {
min-height: 150vh;
padding-top: 80px;
}
</style>
<header />
<main>Lorem ipsum</main>
You can see that we're giving our main
tag a padding-top
equal to the height
of the header otherwise the header (being fixed
) would cover the top of main
. We're also giving main
some min-height
so we can be sure that we're able to scroll up and down and test our component manually.
As it stands, we've created a fixed header that stays put as you scroll down. Not great, not terrible. Here is our starting point in a code sandbox:
The Plan: Pin or Unpin
In order to hide or show the header
, we shall target it with a conditional class so that we can joyfully control its CSS. One class will serve to pin the header by setting the top
property to 0
, and the other will bravely unpin it by setting top
to -80px
, which will hide it out of view (based on its own height of 80px).
Let's add a transition on header
while we're dealing with the CSS so any change will occur over 0.3 second instead of being instantaneous and jarring and, quite frankly, unusable. I dutifully propose this extra bit of CSS:
header {
/* ... existing properties */
transition: all 0.3s linear;
}
.pin {
top: 0;
}
.unpin {
top: -80px;
}
It will be up to us to add and remove the appropriate class in response to the user actively scrolling. Fingers crossed, everybody.
Using Svelte State
Let's create some state to hold the value of a headerClass
that we can then refer to in the HTML. Well, state is simply a JavaScript assignment in Svelte! Let's give our header a starting class of pin
.
<script>
let headerClass = 'pin';
</script>
<header class={headerClass} />
Gotta love it. A simple re-assignment like headerClass = "whatever"
will update our view. We'll do that in just a moment. But let's get our bearings and take stock of our entire component as it stands:
<script>
let headerClass = 'pin';
</script>
<style>
header {
background-color: darkgrey;
height: 80px;
position: fixed;
width: 100%;
transition: all 0.3s linear;
}
main {
height: 150vh;
padding-top: 80px;
}
.pin {
top: 0;
}
.unpin {
top: -80px;
}
</style>
<header class={headerClass} />
<main>Lorem ipsum</main>
Our code is taking shape but everything is the same visually: still a boring old fixed header. Clearly, we have to react in some way to the user actively scrolling (and eventually update headerClass
)!
Scrolling Detection
How do we detect vertical scrolling in the first place?
Well... there is a scroll event listener on window
and we can read the vertical scroll position at any time from window.scrollY
. So we could wire up something like this:
// meh
window.addEventListener('scroll', function() {
scroll_position = window.scrollY;
// figure out class name
}
We would have to do this when the component mounts and remember to remove the listener when the component is destroyed. Certainly, it's a possibility.
However, we can do less typing in Svelte: we can use the <svelte:window>
element and even bind to the window.scrollY
position so it's available to us as it's changing. In code, it looks like this:
<script>
let y;
</script>
<svelte:window bind:scrollY={y}/>
<span>{ y }</span>
The above code is a valid component. The value of y
in the span
will change as you scroll up and down the page (try it in a sandbox). Furthermore, we don't have to worry about removing the listener when using svelte:window
, nor worry about checking if window
even exists (shall the code be ran server-side). Well, that's pretty cool!
Reactive Declarations
So we have our scroll position y
over time. From this stream of data, we can derive our class name. But how shall we even store a new value every time y
changes? Svelte offers reactive declarations with the $:
syntax. Check out this introductory example:
<script>
let count = 1;
$: double = count * 2;
count = 2;
</script>
<span>
{ double }
</span>
The span
will hold a value of 4 as soon as we've re-assigned count
to 2
.
In our case, we want headerClass
to be reactive to the y
position. We'll move our logic in a function of its own, much like this:
<script>
let y = 0;
let headerClass = 'pin'
function changeClass(y) {
// do stuff
}
$: headerClass = changeClass(y);
</script>
In short, we can update the class
of the header
whenever the scroll position y
changes. Well, it seems we are getting closer to our objective!
What Class Name?
So we must focus on this newly introduced changeClass
function which is in fact the last bit of implementation. It should return a string,'"pin"' or '"unpin"', and then our CSS can swing (actually, slide) into action.
Base Case
If the scroll direction doesn't change, for example if the user was scrolling down and is still scrolling down, we don't need to do anything at all but return the class name as it was. Let's make that our default case:
let headerClass = 'pin';
function changeClass(y) {
let result = headerClass;
// todo: change result as needed
return result;
}
So that's our base case taken care of. But the function should return 'pin' if the user starts scrolling up, and 'unpin' if they start scrolling down. We're jumping a bit ahead of ourselves because right now we don't even know which way the user is scrolling; we only have a stream of y
positions, so let's figure that out.
Scroll Direction
We need to compare the last y
position to the one we're currently holding to know the distance that was scrolled in pixels. So we need to store some lastY
at the end of each scroll cycle, then the next scroll event can use it.
let headerClass = 'pin';
let lastY = 0;
function changeClass(y) {
let result = headerClass;
// do stuff, then
// just before returning the result:
lastY = y;
return result;
}
Now we have a lastY
to work with so let's get our scroll direction with it. If lastY - y
is positive the user is scrolling down, else they're scrolling up.
let headerClass = 'pin';
let y = 0;
let lastY = 0;
function changeClass(y) {
let result = headerClass;
// new:
const scrolledPxs = lastY - y;
const scrollDirection = scrolledPxs < 0 ? "down" : "up"
// todo: did the direction change?
lastY = y;
return result;
}
To determine if the scrolling direction changed, we can compare it to the last scroll direction, much like we did for lastY
in fact. We'll initialize it to "up"
so we can trigger our effect (hiding the header) on the initial scroll down.
let headerClass = 'pin';
let y = 0;
let lastY = 0;
let lastDirection = 'up'; // new
function changeClass(y) {
let result = headerClass
const scrollPxs = lastY - y;
const scrollDirection = scrolledPxs < 0 ? "down" : "up"
// new:
const changedDirection = scrollDirection !== lastDirection;
// todo: change result if the direction has changed
lastDirection = scrollDirection;
lastY = y;
return result;
}
The Right Class
If my calculations are correct, there is only one step left: to re-assign result
when the scrolling has actually changed direction, which we now know.
let headerClass = 'pin';
let y = 0;
let lastY = 0;
let lastDirection = 'up';
function changeClass(y) {
let result = headerClass
const scrollPxs = lastY - y;
const scrollDirection = scrolledPxs < 0 ? "down" : "up"
const changedDirection = scrollDirection !== lastDirection;
if(changedDirection) { // new
result = scrollDirection === 'down' ? 'pin' : 'unpin';
lastDirection = scrollDirection;
}
lastY = y
return result;
}
And that does trick! Thanks to our conditional class on header
and our CSS, we find ourselves with a headroom-style header!
The Whole Thing
Let's see the whole Svelte component, shall we? Let's treat ourselves to a CSS Variable so we don't have that hard-coded 80px
header height in multiple places.
<script>
let headerClass = "pin";
let y = 0;
let lastY = 0;
let lastDirection = "up";
function changeClass(y) {
let result = headerClass;
const scrolledPxs = lastY - y;
const scrollDirection = scrolledPxs < 0 ? "down" : "up";
const changedDirection = scrollDirection !== lastDirection;
if (changedDirection) {
result = scrollDirection === "down" ? "unpin" : "pin";
lastDirection = scrollDirection;
}
lastY = y;
return result;
}
$: headerClass = changeClass(y);
</script>
<svelte:window bind:scrollY={y}/>
<style>
:root {
--header-height: 80px;
}
header {
background-color: darkgrey;
height: var(--header-height);
position: fixed;
width: 100%;
transition: all 0.3s linear;
}
main {
height: 150vh;
padding-top: var(--header-height);
}
.pin {
top: 0;
}
.unpin {
top: calc(var(--header-height) * -1);
}
</style>
<header class={headerClass} />
<main>Lorem ipsum</main>
Here is a sandbox with this code for your enjoyment:
Thanks for reading and happy coding! Please feel free to leave a comment or connect with me on twitter.
Top comments (7)
Lovely share and a fun teaching project. Really love seeing Svelte breaking through!
Typo maybe: We would have to this when the component, seems like it is missing a word or I am having a hard time understanding what is "this" (the usual issue in JS)?
"we would have to DO this...."
Fixed it. Thanks.
sorry: "this" is not a function
Thank you for this - I just used it for my own web app!
May I make a recommendation? Some browsers (Safari or Browsers on iOS) will let you scroll "above" 0, into the negative numbers. This will cause the header to collapse after the user scrolls to the top. I would recommend changing the following line in your
changeHeaderClass
:lastY = y;
to:
lastY = y >= 0 ? y : 0;
This will prevent the
lastY
value from becoming negative.Or, instead - you can short-circuit the entire function by adding the following to the first line of it:
if (y < 0) return 'pin';
Which would technically be more efficient I guess 🙃
I built a
svelte-headroom
component if anybody reading this is interested:github.com/collardeau/svelte-headroom
You did a little extra in your code sandbox it looks like. I really like the hysteresis effect with the added tolerance. I feel like that minor detail added the polish that makes it feel great. :)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.