DEV Community

Cover image for Sticky Transitioning Veggie Hamburger πŸ”
Florian Rappl
Florian Rappl

Posted on

Sticky Transitioning Veggie Hamburger πŸ”

In our efforts to make the page faster and even faster we also explored different ways of just omitting user interactivity altogether. One place we looked at intensively was the header (or general layout). The header contained a bit of interactivity in form of a React component.

The job of the header component is:

  • on mobile show a persistent header bar with a hamburger instead of the normal entries
  • on desktop show all the menu entries, but switch the appearance once scrolling occurs (full height to less height with a shadow below)
  • the menu on mobile should not have any scrolling (i.e., remove scrollbar if any)

In practice this looks like the following on desktop:

Desktop header

And for mobile the appearance changes to this:

Mobile header

Let's see how we went from the original to a non-JS version that behaves the same.

Original Meat Burger

Alright, we start with the original version of our header (the "hamburger"). To stay rather generic for this article we prepared a small sample that evolves with every step.

We start with the original:

Most of the code is in the CSS-in-JS. You don't need to use the CSS-in-JS here (we actually use SASS), but it easily brings the two parts (CSS and React) that are needed for the component together.

The critical part is that within the component we need to use some hooks (i.e., interactivity) to make it work:

export const PageHeader = ({ children }) => {
  const [open, setOpen] = React.useState(false);
  const header = useStickyHeader();
  useLockBodyScroll(open);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

The goal is thus to reduce it. Let's start with the easy thing first: How to get the hamburger menu working without JS.

Less Meat - Same Taste

How can we toggle two states using CSS and no JS? Well, first we need to understand that CSS inherently allows us to distinguish between states. For instance, CSS allows to select based on

  • empty
  • hovered
  • focused

and more states directly. Among those are also special states of some form fields, e.g., if a checkbox is checked.

So instead of toggling based on a data- attribute, i.e., the following code in CSS:

header[data-open="true"] {
  display: flex;
  height: 100%;
}
Enter fullscreen mode Exit fullscreen mode

we can toggle this based on another selector:

header:has(#header-hamburger-open:checked) {
  display: flex;
  height: 100%;
}
Enter fullscreen mode Exit fullscreen mode

This assumes that a new element with the ID header-hamburger-open has been introduced. This is a checkbox that is hidden to the user. The effect of hiding the checkbox can be done quite easily using CSS, too:

#header-hamburger-open {
  visibility: collapse;
  height: 0;
}
Enter fullscreen mode Exit fullscreen mode

Personally, I like to use visibility: collapse as it transports the meaning the best. Unfortunately, for most elements Webkit-based/derived browsers will behave for collapse like for hidden - i.e., reserving the original space but not rendering the element.

Only Firefox behaves in a way that aligns with the original meaning (but contrary to the spec - which is actually weird in this scenario). That's why we need the additional height: 0 to also prevent allocating same space on Webkit-based browsers such as Chrome.

With this in mind we replace the original button inside the navbar-toggler with the following code:

<input type="checkbox" id="header-hamburger-open" />

<div className="navbar-toggler">
  <label
    htmlFor="header-hamburger-open"
    data-toggle="collapse"
    aria-controls="navbar-menu-content"
    aria-label="Toggle navigation"
  >
    πŸ”
  </label>
</div>
Enter fullscreen mode Exit fullscreen mode

When clicking on the label the htmlFor attribute is used by the browser. This will lead to toggling the checkbox, which - as per our CSS structure - will toggle the menu.

You can play around with this in the updated sample.

Now that the easy part is done - let's look at the final step for making the truly JS-free: Having the sticky positioning fulfilled.

Veggie Version - Original Taste

The main complication when moving the sticky header from a JS transition to a CSS one is that CSS has no idea about scrolling.

Let's see the original hook / code to trigger the transition:

function useStickyHeader() {
  const header = React.useRef(undefined);
  React.useLayoutEffect(() => {
    if (header.current) {
      const sticky = header.current.offsetTop;
      window.onscroll = () => {
        if (window.pageYOffset > sticky) {
          header.current.classList.add("sticky");
        } else {
          header.current.classList.remove("sticky");
        }
      };
    }

    return () => (window.onscroll = undefined);
  }, [header.current]);
  return header;
}
Enter fullscreen mode Exit fullscreen mode

So by applying the CSS class sticky we transform the original header to the sticky one. Without JavaScript - this won't be possible.

Maybe - so the idea - it is possible to have something set from the beginning. So the idea is to have the right layout and not rely on a scrolling-based transition.

The starting point should be the CSS declaration position: sticky. Using position: sticky we can tell the browser to make the menu sticky (in relation to its scrolling container) without relying on a transition.

The problem with position: sticky is that we can only tell the browser what "position" we want to settle in. For instance, we can tell the browser top: -10px to keep moving the header bar until it's 10 pixels inside the containers hidden area, i.e., shifted to the top by 10px. There is no way to add another shadow or other things we might be interested in...

This is the point where we could call it a day, marking the task as "impossible" and move on.

CSS one does not simply meme

Luckily, there are still some tricks we can apply in this case. Let's look at what we want:

Header transition

In other words, we want to "reveal" part of the shadow when we move up. The moving up part we get covered by using position: sticky as outlined - now the shadow part could be tricked in by really introducing such layers.

What we mean specifically is that we could introduce two more "virtual" elements (using ::before and ::after pseudo selectors):

  • one element moving together with the sticky header (i.e., absolutely positioned) - initially "hiding" the shadow
  • one element as a shadow that is being fixed; thus not moving with the sticky header (initially being hidden behind the absolutely positioned element)

Note that both need to be in the foreground. This means we have a layering such as:

Layering of elements

The real header (blue) is now shorter, but extended with the overlay element (green), which is initially hiding the shadow (purple). Once the page (red) scrolls, the header scrolls up to a certain point. Together with it the overlay is supposed to scroll, thus revealing the shadow:

Scrolling of elements

In theory that should work - but in reality it will all be determined by the specific values we pick for the positioning of these elements.

In CSS the following is needed to introduce the two virtual elements:

header + *::before {
  position: fixed;
  width: 100%;
  height: 45px;
  content: '';
  background: #ccc;
  box-shadow: 0 3px 5px rgba(57, 63, 72, 0.3);
  top: 0;
  left: 0;
  z-index: 9999;
}

header + *::after {
  position: absolute;
  width: 100%;
  height: 55px;
  content: '';
  background: #ccc;
  top: 0;
  left: 0;
  z-index: 9999;
}
Enter fullscreen mode Exit fullscreen mode

The header itself is positioned in a way that makes it be in front of these layers:

header {
  width: 100%;
  position: sticky;
  top: -5px;
  z-index: 10000;
}
Enter fullscreen mode Exit fullscreen mode

This creates the effect we are looking for - and without requiring any JavaScript whatsoever.

Conclusion

Going to a full non-JS version of our header made the lighthouse score even better - being a full 100 on most / all pages. Now, whenever JS is needed it would be just there to enhance a certain view or give more details. There is no more JavaScript to actually enable navigation or other crucial means.

As far as the sticky header is concerned, the shown trick only works in scenarios like ours. If you need more transformations then finding a good workaround might be more difficult. In the future that may change, but for now position: sticky is quite limited and requires such tricks to also have scenarios such as ours covered.

Top comments (2)

Collapse
 
efpage profile image
Eckehard

One reason to use CSS -in-JS could be the easier handling. You can put all the complicated things together in a module and just use one function call to apply all this to your menue. And - as JS is a programming language - you can react to whatever you want, not limited to what CSS provides. What if you want your menue to act differently on christmas, or on different machines? There sure is an easy solution with JS, but not necessarily with CSS.

Collapse
 
florianrappl profile image
Florian Rappl

Well the given scenario could be handled differently already on the server-side / when serving your page.

All in all I hope the CSS-in-JS did not confuse you. It was not needed in the beginning and is not needed right now. You can do the same code (at every step) with standard CSS. I just did not want to separate files / complicate the setup (as the original code used SASS I wanted the same convenience for the examples without having to use SASS).

The main objective was still to get the hamburger + sticky working exactly as beforehand - but without any JS.