If you specify `bottom: 0` for position: sticky, why is it doing something different from the specs?

According to the MDN, fixed position elements are treated as relative position elements until the specified threshold is exceeded

It’s all a matter of language here because the above sentence doesn’t mean the element will necesseraly start position:relative then become fixed. It says until the specified threshold is exceeded. So what if initially we have the specified threshold exceeded? This is actually the case of your example.

In other words, position:sticky has two states.

  1. It’s treated as relative
  2. It’s treated as fixed when the specified threshold is exceeded

Which one will be the first will depend on your HTML structure.

Here is a basic example to illustrate:

body {
  height:150vh;
  margin:0;
  display:flex;
  flex-direction:column;
  border:2px solid;
  margin:50px;
}

.b {
  margin-top:auto;
  position:sticky;
  bottom:0;
}

.a {
  position:sticky;
  top:0;
}
<div class="a"> 
  I will start relative then I will be fixed
</div>
<div class="b"> 
I will start fixed then I will be relative
</div>

You can also have a mix. We start fixed, become relative and then fixed again:

body {
  height:250vh;
  margin:0;
  display:flex;
  flex-direction:column;
  border:2px solid;
  margin:50px;
}
body:before,
body:after {
  content:"";
  flex:1;
}

.a {
  position:sticky;
  top:0;
  bottom:0;
}
<div class="a"> 
  I will start fixed then relative then fixed
</div>

As you can see in the above examples both states are independent. If the condition of the position:fixed is true then we have position:fixed, if not then it’s relative.

We can consider that the browser will implement this pseudo code:

on_scroll_event() {
   if(threshold exceeded)
      position <- fixed
   else
      position <- relative
}

For more accurate and complete understanding of the mechanism, you need to consider 3 elements. The sticky element (and the values of top/bottom/left/right), the containing block of the sticky element and the nearest ancestor with a scrolling box.

  1. The nearest ancestor with a scrolling box is simply the nearest ancestor with overflow different from visibile and by default it will be the viewport (as I explained here: What are `scrolling boxes`?). The scroll on this element will control the sticky behavior.
  2. The containing block for a sticky element is the same as for a relative element ref

Left/top/bottom/right are calculated relatively to the scrolling box and the containing block will define the limit of the sticky element.

Here is an example to illustrate:

body {
 margin:0;
}
.wrapper {
  width:300px;
  height:150px;
  border:2px solid red;
  overflow:auto;
}

.parent {
   height:200%;
   margin:100% 0;
   border:2px solid;
}

.sticky {
  position:sticky;
  display:inline-block;
  margin:auto;
  top:20px;
  background:red;
}
.non-sticky {
  display:inline-block;
  background:blue;
}
<div class="wrapper"><!-- our scrolling box -->
  <div class="parent"><!-- containing block -->
    <div class="sticky">I am sticky</div>
    <div class="non-sticky">I am the relative position</div>
  </div>
</div>

Initially our element is hidden which is logical because it cannot be outside its containing block (its limit). Once we start scrolling we will see our sticky and relative elements that will behave exactly the same. When we have a distance of 20px between the sticky element and the top edge of the scrolling box we reach the threshold and we start having position:fixed until we reach again the limit of the containing block at the bottom (i.e. we no more have space for the sticky behavior)

Now let’s replace top with bottom

body {
 margin:0;
}
.wrapper {
  width:300px;
  height:150px;
  border:2px solid red;
  overflow:auto;
}

.parent {
   height:200%;
   margin:100% 0;
   border:2px solid;
}

.sticky {
  position:sticky;
  display:inline-block;
  margin:auto;
  bottom:20px;
  background:red;
}
.non-sticky {
  display:inline-block;
  background:blue;
}
<div class="wrapper"><!-- our scrolling box -->
  <div class="parent"><!-- containing block -->
    <div class="sticky">I am sticky</div>
    <div class="non-sticky">I am the relative position</div>
  </div>
</div>

Nothing will happen because when there is a distance of 20px between the element and the bottom edge of the scrolling box the sticky element is already touching the top edge of the containing block and it cannot go outside.

Let’s add an element before:

body {
 margin:0;
}
.wrapper {
  width:300px;
  height:150px;
  border:2px solid red;
  overflow:auto;
}

.parent {
   height:200%;
   margin:100% 0;
   border:2px solid;
}

.sticky {
  position:sticky;
  display:inline-block;
  margin:auto;
  bottom:20px;
  background:red;
}
.non-sticky {
  display:inline-block;
  background:blue;
}

.elem {
  height:50px;
  width:100%;
  background:green;
}
<div class="wrapper"><!-- our scrolling box -->
  <div class="parent"><!-- containing block -->
  <div class="elem">elemen before</div>
    <div class="sticky">I am sticky</div>
    <div class="non-sticky">I am the relative position</div>
  </div>
</div>

Now we have created 50px of space to have a sticky behavior. Let’s add back top with bottom:

body {
 margin:0;
}
.wrapper {
  width:300px;
  height:150px;
  border:2px solid red;
  overflow:auto;
}

.parent {
   height:200%;
   margin:100% 0;
   border:2px solid;
}

.sticky {
  position:sticky;
  display:inline-block;
  margin:auto;
  bottom:20px;
  top:20px;
  background:red;
}
.non-sticky {
  display:inline-block;
  background:blue;
}

.elem {
  height:50px;
  width:100%;
  background:green;
}
<div class="wrapper"><!-- our scrolling box -->
  <div class="parent"><!-- containing block -->
  <div class="elem">elemen before</div>
    <div class="sticky">I am sticky</div>
    <div class="non-sticky">I am the relative position</div>
  </div>
</div>

Now we have both behavior from top and bottom and the logic can be resumed as follow:

on_scroll_event() {
    if( top_sticky!=auto && distance_top_sticky_top_scrolling_box <20px && distance_bottom_sticky_bottom_containing_block >0) {
          position <- fixed
     } else if(bottom_sticky!=auto && distance_bottom_sticky_bottom_scrolling_box <20px && distance_top_sticky_top_containing_block >0) {
        position <- fixed
     } else (same for left) {
        position <- fixed
     } else (same for right) {
        position <- fixed
     } else {
        position <- relative
     }
}

Leave a Comment