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

This is a question when I read an article on the MDN position property. I thought that there was a clear difference between the behavior of sticky described there and the actual behavior.


According to the MDN, fixed position elements are treated as relative position elements until the specified threshold is exceeded, and when the threshold is exceeded, they are treated as fixed position elements until the boundary of the parent element is reached (Link).

Sticky positioning can be thought of as a hybrid of relative and fixed positioning. A stickily positioned element is treated as relatively positioned until it crosses a specified threshold, at which point it is treated as fixed until it reaches the boundary of its parent. For instance...

#one { position: sticky; top: 10px; } 

...would position the element with id one relatively until the viewport were scrolled such that the element would be less than 10 pixels from the top. Beyond that threshold, the element would be fixed to 10 pixels from the top.

So, I created the following code and confirmed the operation.

body {
  margin: 0;
}

.container {
  display: flex;
  flex-direction: column;
}

.container>* {
  width: 100%;
}

header {
  background: #ffa;
  height: 130vh;
}

main {
  background: #faf;
  height: 210vh;
}

footer {
  background: #faa;
  height: 8vh;
  position: sticky;
  bottom: 0;
}

.footer {
  background: #aff;
  height: 100vh;
}
<div class="container">
  <header>HEADER</header>
  <main>MAIN CONTENT</main>
  <footer>FOOTER</footer>
  <div class="footer"></div>
</div>

According to the MDN article, this code "is a relative placement element until the position of the element is less than 0px from the bottom of the viewport by scrolling the viewport, and becomes a fixed placement element when it is more than 0px from the bottom" I was thinking.

However, the result is the action of "Scroll to the fixed position element until the position of the element becomes smaller than 0px from the lower end of the viewport by scrolling the viewport, and become the relative arranged element when larger than 0px from the lower end".


Why does specifying the bottom:0 result in the opposite of the behavior shown in MDN?

When top: 0 is specified, the relative position is applied when the element does not reach bottom: 0 of the viewport, and when it reaches, fixed position is applied. When bottom: 0 is specified, the opposite is true. The relative position is applied when the element does not reach the bottom: 0 of the viewport, the fixed position is applied when it is reached

I read CSS3 but its mechanism was difficult to read


Solution 1:

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
     }
}

Solution 2:

The specs are difficult to understand so here is my attempt to explain them based on MDN. Some definitions first:

  • sticky element – an element with position: sticky
  • containing block – the parent of sticky element
  • flow root – lets just say that it means viewport

A sticky element having position: sticky; top: 100px; is positioned as follows:

  • It is positioned according to the normal flow
  • And its top edge will maintain a distance of at least 100px from the top edge of the flow root
  • And its bottom edge cannot go below the bottom edge of the containing block

The following example shows how these rules operate:

body { font: medium sans-serif; text-align: center; }
body::after { content: ""; position: fixed; top: 100px; left: 0; right: 0; border: 1px solid #F00; }
header, footer { height: 75vh; background-color: #EEE; }
.containing-block { border-bottom: 2px solid #FA0; background: #DEF; }
.containing-block::after { content: ""; display: block; height: 100vh; }
.before-sticky { border-bottom: 2px solid #080; padding-top: 50px; }
.after-sticky { border-top: 2px solid #080; padding-bottom: 50px; }
.sticky { position: sticky; top: 100px; padding-top: 20px; padding-bottom: 20px; background-color: #CCC; }
<header>header</header>
<div class="containing-block">
  <div class="before-sticky">content before sticky</div>
  <div class="sticky">top sticky</div>
  <div class="after-sticky">content after sticky</div>
</div>
<footer>footer</footer>

Likewise, a sticky element having position: sticky; bottom: 100px; is positioned as follows:

  • It is positioned according to the normal flow
  • And its bottom edge will maintain a distance of at least 100px from the bottom edge of the flow root
  • And its top edge cannot go above the top edge of the containing block

body { font: medium sans-serif; text-align: center; }
body::after { content: ""; position: fixed; bottom: 100px; left: 0; right: 0; border: 1px solid #F00; }
header, footer { height: 75vh; background-color: #EEE; }
.containing-block { border-top: 2px solid #FA0; background: #DEF; }
.containing-block::before { content: ""; display: block; height: 100vh; }
.before-sticky { border-bottom: 2px solid #080; padding-top: 50px; }
.after-sticky { border-top: 2px solid #080; padding-bottom: 50px; }
.sticky { position: sticky; bottom: 100px; padding-top: 20px; padding-bottom: 20px; background-color: #CCC; }
<header>header</header>
<div class="containing-block">
  <div class="before-sticky">content before sticky</div>
  <div class="sticky">bottom sticky</div>
  <div class="after-sticky">content after sticky</div>
</div>
<footer>footer</footer>

I hope this is simple enough explanation.