DEV Community

Thomas Collardeau
Thomas Collardeau

Posted on • Updated on

Building a Headroom-Style Header in Svelte

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)

Collapse
 
vekzdran profile image
Vedran Mandić

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)?

Collapse
 
collardeau profile image
Thomas Collardeau

"we would have to DO this...."

Fixed it. Thanks.

Collapse
 
nmrshll profile image
Nicolas Marshall • Edited

sorry: "this" is not a function

Collapse
 
wlockiv profile image
wlockiv

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 🙃

Collapse
 
collardeau profile image
Thomas Collardeau

I built a svelte-headroom component if anybody reading this is interested:

github.com/collardeau/svelte-headroom

Collapse
 
thompcd profile image
Corey Thompson

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.