The sliding sidewalk: preventing a dropdown element from exceeding the screen

“Show me the code already!”

Link to the demo on Codepen

There are a few things in web development that are surprisingly hard to do. Displaying a box below an element seems fairly straightforward. We do this quite often. Think about tooltips, drop-down (aka. fly-out) menus or login forms that appear once you click the account button. How do we usually do this? The element controlling the other element's visibility receives the value relative as the position property. The element which drops down/flies out/pops up is assigned the value absolute. Below you find a minimal implementation of a login form that is shown once a button is clicked.

<div class="wrapper">
  <button class="toggle-button" aria-haspopup="true" aria-expanded="false">
    My account
  </button>
  <form class="login-form" hidden>
    <div>
      <label for="login-menu-mail">Mail</label><br>
      <input id="login-menu-mail" type="email" name="mail">
    </div>
    <div>
      <label for="login-menu-password">Password</label><br>
      <input id="login-menu-password" type="password" name="password">
    </div>
    <button>log in</button>
  </form>
</div>
.wrapper {
  position: relative;
}
.login-form {
  position: absolute;
}
const toggleButton = document.querySelector('.toggle-button')
const loginForm = document.querySelector('.login-form')
toggleButton.addEventListener('click', () => {
  loginForm.hidden = !loginForm.hidden
  toggleButton.setAttribute('aria-expanded', !loginForm.hidden)
})

Question to you, dear reader: I am not sure if it makes sense to implement this using the <details> element which provides the toggling of the visibility out-of-the-box (no JavaScript 🙌). However, I am not aware if it is semantically correct or if it affects the component's accessibility negatively. I would be happy to hear your opinion on that! (Demo below)

<details>
  <summary>My account</summary>
  <form class="login-form">
    <div>
      <label for="login-menu-mail">Mail</label><br>
      <input id="login-menu-mail" type="email" name="mail">
    </div>
    <div>
      <label for="login-menu-password">Password</label><br>
      <input id="login-menu-password" type="password" name="password">
    </div>
    <button>log in</button>
  </form>
</details>
detail {
  position: relative;
}
.login-form {
  position: absolute;
}

The challenge

So far so good. But do you remember that I mentioned that this is harder than it seems? When our button is close to the screen's edge the drop-down might exceed the screen 😱. This happens because the absolutely positioned element is aligned relative to the button and is not aware of the viewport. The screen capture below demonstrates this problem (or see the demo with the bug).

JavaScript can help here. We want to find out if the box exceeds the screen. For that purpose, we take a look at how wide the window is. But be careful, window.innerWidth will give you the width including the scroll bar. We compare this with the right coordinate of the box. With this information we can act accordingly (e.g. right-align the box).

const windowWidth = document.body.getBoundingClientRect().width
const boxRight = box.getBoundingClientRect().right
const exceeding = windowWidth < boxRight

The disadvantage of this approach is that we have to run the checks each time the box appears and the window or the box resizes. Your implementation will probably need to reset the styles before checking (also a timeout maybe). I am happy to announce a CSS-only solution for this recurring problem: the sliding sidewalk.

How does it work

The idea is to have a dedicated area spanning the viewport horizontally on which the dropdown's content can slide using sticky positioning. This sidewalk is a wrapping element with absolute or fixed positioning inside a wrapper of the triggering element. The element whose visibility is toggled—the dropdown's actual content—is position: sticky. At this point, we say goodbye to our visitors with Internet Explorer. Then, we set the left value to where we expect the dropdown to be on the largest viewport. The demo uses the calc() function to compute the value based on the maximum container width and spacings and the width of the triggering element. Or as CSS:

.dropdown__content-wrapper {
position: absolute; /* or fixed */
left: 0;
width: 100%; /* or right: 0 */
}
.dropdown__content {
position: sticky;
left: calc(...);
}

The video below shows the result of the CSS-only solution applied to the demo. It works!

Caveats

Ordered from worst to ¯\_(ツ)_/¯

  1. The horizontal orientation of the element dropping down is not relative to the trigger. This makes it necessary to know where the trigger is placed on the page. This makes this solution rather inflexible and unsuitable as a generic solution.
  2. Transforming the element dropping down does not work. This means that we cannot use transform: translateX(-50%) to centre the element.
  3. Fixed positioning of the wrapper does not work in Firefox.