← All writing
Craft · · 7 min

position: sticky and its containing block

CSS Debugging

Four declarations, all of them correct. It didn’t move.

This was a filters rail on a client’s search results page. position: sticky with a top offset, in a column with a couple thousand pixels of results running alongside it, in a browser that’s supported the property since January. The rail scrolled off the top like any other div and took the filters with it.

An hour, most of it spent rereading four lines that were fine.

I’d like to say I went looking at the ancestors early. I didn’t, because the styles panel is right there and it shows you every rule that applies to the element, so it feels like the whole picture. It isn’t, for this one. My rule wasn’t being disabled by a rule at all, or at least not one attached to anything I had selected.

Relative until it isn’t

Sticky is two positions with a threshold in between, and you have to pick the threshold yourself.

Before the element’s box would scroll past that point it behaves exactly like position: relative. It’s in the flow, it takes up space, everything around it lays out as though nothing unusual is going on. Once the scroll crosses the threshold, the element stops moving and behaves like position: fixed, except it’s measured against its nearest scrolling ancestor instead of the viewport, and only for as long as its own containing block is still on screen. When the parent’s box leaves, the element goes with it.

That last clause is the entire feature, and it’s the part nobody puts in the one-line explanation.

Three things will make it do nothing whatsoever, and the first two are cheap. (1) You never gave it a threshold, since position: sticky on its own is inert, not “sticks to the top by default,” and it needs top, bottom, left or right with a value before the browser has anywhere to hold it. And (2) there’s nowhere to go, because a sticky element in a wrapper exactly as tall as itself sticks for the 0px available to it, which looks precisely like a sticky element that doesn’t work.

The third one is what got me.

The call is coming from inside the house

Any ancestor with an overflow other than visible becomes the scrolling box your element is measured against. Not the window. That ancestor, whichever one it is, and hidden counts the same as auto and scroll. The element sticks obediently inside a box that never scrolls, at a threshold it will therefore never reach.

All of that is documented, above board, working as specified. But nothing about the symptom points at it. The element you’re inspecting is correct, the rule you wrote is correct, the parent is an unremarkable div, and the declaration doing the damage is sitting on some third thing that doesn’t inherit to you, doesn’t cascade into you, and doesn’t show up in your computed styles.

I have met the enemy

It was overflow: hidden on a .row, in a partial I hadn’t opened since we built the thing. I’m the one who put it there.

I even wrote about that declaration three years ago, as the second of four ways to stop a parent collapsing around its floated children. It establishes a new block formatting context, the parent contains the floats, one property, super tidy. I wrote down the cost too, which was that a dropdown would get cropped six months later and I’d have no memory of why.

That line meant one thing when I typed it: contain the floats. It now also means “this box is a scroll container, kindly measure any sticky positioning against it,” and nobody asked me about the second part. The language grew a feature and my old declaration picked up a job on the way past.

And it’s absolutely everywhere. It’s on every clearfix nobody ever converted to the pseudo-element version, every wrapper that used to have a carousel in it, every “stop this thing bleeding out sideways on mobile” fix anyone has committed in a hurry, which is usually overflow-x: hidden on the body and which disables sticky for the entire document at a stroke. Half the layouts I’ve worked on have that rule in them somewhere, with no comment attached, applied to something generic like .wrapper.

In March I wrote about why those wrappers aren’t going anywhere yet, since the float fallback IE11 still gets is built on them. They’re staying, so whatever else somebody declared on them is staying too.

Go and ask the ancestors

Reading up the tree by hand in the styles panel is what ate the hour. Walking it in the console takes about as long as typing it:

DevTools console
let el = document.querySelector('.filters');
while ((el = el.parentElement)) {
  const s = getComputedStyle(el);
  if (s.overflowX !== 'visible' || s.overflowY !== 'visible') {
    console.log(s.overflowX, s.overflowY, el);
  }
}
JavaScript

Every ancestor that could be eating it, in order, all at once. Mine was the second thing it logged.

It’s a decision instead of a lookup, since the rule is there for a reason even if the reason has outlived everyone who remembers it. If it’s containing floats, swap in the clearfix pseudo-element, which does the same job without making any claims about scrolling. If it’s genuinely cropping something, move the crop down onto the thing being cropped instead of the row around it. If it’s the body rule, find whatever is overflowing and fix that, which I know is easier to type than to do.

The constraint is the good part

Once I stopped fighting the “can’t leave its parent” rule, that rule turned out to be the reason to use any of this.

A section heading that rides down its own section and hands over to the next one is an effect I’ve built twice with a scroll listener, a class toggle, and a measurement of the parent’s height on load and on resize. It was always slightly wrong at the boundary because the boundary was a number I calculated instead of a box the browser already knew about. Same for table headers that stay put per table, and for the filters rail itself, which now follows you down the results and stops when the results do instead of hovering over the footer looking lost.

Nobody has to have the support conversation

Chrome has had it since January, Firefox for years. Safari wants -webkit-sticky as the value, which is an unusual place for a vendor prefix to live and catches people for exactly that reason. Edge doesn’t have it. IE never will.

sticky.css
.section__heading {
  /* Safari wants the prefix on the value, not the property */
  position: -webkit-sticky;
  position: sticky;
  top: 0;
}
CSS

Which is fine, and I don’t get to say that often. In January I wrote about the support matrix and how the answer otherwise gets settled in week six by whoever happens to be standing there. This feature doesn’t need that conversation. A browser that doesn’t understand the declaration throws it away and gives you a heading that scrolls normally, and a heading that scrolls normally is a heading. There’s nothing to polyfill, nothing to apologize for, and no row to add to the spreadsheet.

Note to self, literally

The habit I’ve come out of this with is a small one. I write a comment on overflow: hidden now, saying what it’s holding in.

That feels like a pretty thin thing to call a lesson. It is, because the actual lesson doesn’t have an action attached to it. I can’t audit a stylesheet for declarations that are going to mean something extra in three years, since the extra meaning hasn’t been specified yet and the working group hasn’t met about it. I can only leave behind a note saying why this line is here, so the next person to read it, who is going to be me, has something to weigh against whatever the property has come to mean by then.

Read similar posts
9 min

z-index: 9999

A size dropdown on a client's product grid kept rendering underneath the row of products below it, and the 9999 already sitting on it had been doing nothing at all for 3 years.

2 min

light-dark()

Two color values in one declaration, picked by the color scheme, which removes most of the reason a theme needed a second block of custom properties at all.