225

position: sticky works on some mobile browsers now, so you can make a menu bar scroll with the page but then stick to the top of the viewport whenever the user scrolls past it.

But what if you want to restyle your sticky menu bar slightly whenever it's currently 'sticking'? eg, you might want the bar to have rounded corners whenever it's scrolling with the page, but then as soon as it sticks to the top of the viewport, you want to get rid of the top rounded corners, and add a little drop shadow underneath it.

Is there any kind of pseudoselector (eg ::stuck) to target elements that have position: sticky and are currently sticking? Or do browser vendors have anything like this in the pipeline? If not, where would I request it?

NB. javascript solutions are not good for this because on mobile you usually only get a single scroll event when the user releases their finger, so JS can't know the exact moment that the scroll threshold was passed.

7 Answers 7

176

There is currently no selector that is being proposed for elements that are currently 'stuck'. The Postioned Layout module where position: sticky is defined does not mention any such selector either.

Feature requests for CSS can be posted to the www-style mailing list. I believe a :stuck pseudo-class makes more sense than a ::stuck pseudo-element, since you're looking to target the elements themselves while they are in that state. In fact, a :stuck pseudo-class was discussed some time ago; the main complication, it was found, is one that plagues just about any proposed selector that attempts to match based on a rendered or computed style: circular dependencies.

In the case of a :stuck pseudo-class, the simplest case of circularity would occur with the following CSS:

:stuck { position: static; /* Or anything other than sticky/fixed */ }
:not(:stuck) { position: sticky; /* Or fixed */ }

And there could be many more edge cases that would be difficult to address.

While it's generally agreed upon that having selectors that match based on certain layout states would be nice, unfortunately major limitations exist that make these non-trivial to implement. I wouldn't hold my breath for a pure CSS solution to this problem anytime soon.

21
  • 29
    That's a shame. I was looking for a solution to this problem too. Wouldn't it be fairly easy to simply introduce a rule that says position properties on a :stuck selector should be ignored? (a rule for browser vendors I mean, similar to rules about how left takes precedence over right etc))
    – powerbuoy
    Commented Apr 18, 2016 at 13:01
  • 8
    It isn't just position... imagine a :stuck that changes the top value from 0 to 300px, then scroll down 150px... should it stick or not? Or think about an element with position: sticky and bottom: 0 where the :stuck maybe changes font-size and therefore the elements size (therefore changing the moment in which it should stick)...
    – Roman
    Commented Feb 15, 2017 at 11:18
  • 66
    I believe the same circular problems can be made with many already existing pseudo-classes (e.g. :hover changing width and :not(:hover) changing back again). I would love :stuck pseudo-class and think that the developer should be responsible for not having the circular issues in his code. Commented Sep 10, 2017 at 15:56
  • 33
    Well... I don't really understand this as mistake - it's like saying while cycle is badly designed because it allows endless loop :) However thanks for clearing this up ;) Commented Sep 10, 2017 at 18:22
  • 8
    can :stuck just ignore positional properties? mostly it will just be used to add a freakin shadow anyway...
    – oriadam
    Commented Sep 9, 2019 at 7:28
87

In some cases a simple IntersectionObserver can do the trick, if the situation allows for sticking to a pixel or two outside its root container, rather than properly flush against. That way when it sits just beyond the edge, the observer fires and we're off and running.

const observer = new IntersectionObserver( 
  ([e]) => e.target.toggleAttribute('data-stuck', e.intersectionRatio < 1),
  {threshold: [1]}
);

observer.observe(document.querySelector('nav'));

Stick the element just out of its container with top: -2px, and then target via the stuck attribute...

nav {
  background: magenta;
  height: 80px;
  position: sticky;
  top: -2px;
}
nav[data-stuck] {
  box-shadow: 0 0 16px black;
}

Example here: https://codepen.io/anon/pen/vqyQEK

11
  • 7
    I think that a stuck class would be better than a custom attribute... Is there any specific reason for your choice?
    – collimarco
    Commented Feb 22, 2020 at 14:07
  • 4
    A class works fine too, but this just seems a little bit higher level than that, since it is a derived property. An attribute seems more appropriate to me, but either way it's a matter of taste.
    – rackable
    Commented Feb 23, 2020 at 18:34
  • 2
    I need my top to be 60px because of an already fixed header, so I can't get your example to work
    – FooBar
    Commented Jun 3, 2020 at 11:15
  • 1
    Try adding some top padding to the to whatever is being stuck, maybe padding-top: 60px in your case :)
    – Tim Willis
    Commented Jun 16, 2020 at 17:31
  • 2
    It's just not a matter of taste –— to add invalid HTML5 attributes to elements. I would highly suggest you revise and acknowledge @collimarco 's comment. Commented May 24, 2022 at 18:16
31

I wanted a pure CSS solution that would allow styling a 'stuck' element, as though a ::stuck pseudo-selector exists (alas, still not in 2021).

I have created a pure CSS hack that achieves the effect with no JS and fits my needs. It works by having two copies of the element, one is sticky and the other isn't (unstuck one), and this latter one covers up the sticky element until you scroll by it.

Demo: https://codepen.io/TomAnthony/pen/qBqgErK

Alternative demo: https://codepen.io/TomAnthony/pen/mdOvJYw (this version is more what I wanted, I wanted the sticky items to only appear once they were 'stuck' - it also means no duplicate content.)

HTML:

<div class="sticky">
    <div class="unstuck">
        <div>
        Box header. Italic when 'stuck'.
        </div>
    </div>
    <div class="stuck">
        <div>
        Box header. Italic when 'stuck'.
        </div>
    </div>
</div>

CSS:

.sticky {
    height: 20px;
    display: inline;
    background-color: pink;
}

.stuck {
    position: -webkit-sticky;
    position: sticky;
    top: 0;
    height: 20px;
    font-style: italic;
}

.unstuck {
    height: 0;
    overflow-y: visible;
    position: relative;
    z-index: 1;
}

.unstuck > div {
    position: absolute;
    width: 100%;
    height: 20px;
    background-color: inherit;
}
3
  • 8
    This is a nice solution, as it is free of JS. If text is duplicate, one should also set aria-hidden=true to one of them to avoid accessibility baloney.An even better solution might be to put the duplicate content into data-content="..." and style the :after element with content: attr(data-content). I haven't tried it yet though
    – phil294
    Commented May 20, 2021 at 14:36
  • 1
    I was confused how your alternative demo works at first and then it clicked. Cheeky and I love it! Not what I need, but it's neat :) Commented Sep 8, 2023 at 0:53
  • Not for me since I need to sticky somewhere other than edge of screen, but I like it - and if adding JS is an alternative it can probably be combined with the Intersect trick to instantly hide the .unstuck element as soon as it touches the edge of the screen. That would solve the problem of visibly seeing it scroll away if it is a tall element.
    – AnorZaken
    Commented Jun 29 at 0:00
9

Someone on the Google Developers blog claims to have found a performative JavaScript-based solution with an IntersectionObserver.

Relevant code bit here:

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--top` become visible/invisible at the top of the container.
 * @param {!Element} container
 */
function observeHeaders(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;

      // Started sticking.
      if (targetInfo.bottom < rootBoundsInfo.top) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.bottom >= rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
       fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [0], root: container});

  // Add the top sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--top');
  sentinels.forEach(el => observer.observe(el));
}

I haven't replicated it myself, but maybe it helps someone stumbling over this question.

3

Not really a fan of using js hacks for styling stuff (ie getBoudingClientRect, scroll listening, resize listening), but this is how I'm currently solving the problem. This solution will have issues with pages that have minimizable/maximizable content (<details>), or nested scrolling, or really any curve balls whatsoever. That being said, it's a simple solution for when the problem is simple as well.

let lowestKnownOffset: number = -1;
window.addEventListener("resize", () => lowestKnownOffset = -1);

const $Title = document.getElementById("Title");
let requestedFrame: number;
window.addEventListener("scroll", (event) => {
    if (requestedFrame) { return; }
    requestedFrame = requestAnimationFrame(() => {
        // if it's sticky to top, the offset will bottom out at its natural page offset
        if (lowestKnownOffset === -1) { lowestKnownOffset = $Title.offsetTop; }
        lowestKnownOffset = Math.min(lowestKnownOffset, $Title.offsetTop);
        // this condition assumes that $Title is the only sticky element and it sticks at top: 0px
        // if there are multiple elements, this can be updated to choose whichever one it furthest down on the page as the sticky one
        if (window.scrollY >= lowestKnownOffset) {
            $Title.classList.add("--stuck");
        } else {
            $Title.classList.remove("--stuck");
        }
        requestedFrame = undefined;
    });
})
2
  • Note that scroll event listener is executed on the main thread which makes it a performance killer. Use the Intersection Observer API instead. Commented Jan 20, 2020 at 15:36
  • if (requestedFrame) { return; } It's not a "performance killer" due to the animation frame batching. Intersection Observer is still an improvement though.
    – Seph Reed
    Commented Jan 21, 2020 at 17:35
3

A compact way for when you have an element above the position:sticky element. It sets the attribute stuck which you can match in CSS with header[stuck]:

HTML:

<img id="logo" ...>
<div>
  <header style="position: sticky">
    ...
  </header>
  ...
</div>

JS:

if (typeof IntersectionObserver !== 'function') {
  // sorry, IE https://caniuse.com/#feat=intersectionobserver
  return
}

new IntersectionObserver(
  function (entries, observer) {
    for (var _i = 0; _i < entries.length; _i++) {
      var stickyHeader = entries[_i].target.nextSibling
      stickyHeader.toggleAttribute('stuck', !entries[_i].isIntersecting)
    }
  },
  {}
).observe(document.getElementById('logo'))
3

I came across this thread while trying to apply position: sticky to a <thead> (to fix the table header while scrolling long table). I wanted to apply a white background color to the table header but only when it's "stuck" because its text was overlapping with cell data in the table.

I had the white bg color already defined as a CSS class .white-bg, so I basically wanted to toggle that class.

I had another issue as well: the page had a top-fixed <nav> element that was hiding the table head behind it even when it's "stuck". With the help of the above insightful answers, I was able to fix this via JS as follows:

const navTop = document.querySelector('nav')?.clientHeight ?? 0;
const theadStyle = document.querySelector('thead').style

theadStyle.top = `${navTop}px`;
theadStyle.position = 'sticky';

const observer = new IntersectionObserver( 
    ([e]) => {
        e.target.children[0].classList.toggle('bg-white', e.boundingClientRect.y < 0);
    },
    {threshold: [1]}
);

observer.observe(document.querySelector('table'));

The above code solves several issues for me:

  1. It dynamically compensates for the navbar height (and uses 0 as height if navbar doesn't exist) without having to use a hard-coded height.

  2. The IntersectionObserver doesn't seem to work when directly observing <thead> but works when observing <table>, but that's fine since <thead> is at the top of the table anyway.

  3. The code is triggered only when the top of the table is above the top of the view port but not when the lower part of the table is below the bottom of the view port. This is made possible by checking that e.boundingClientRect.y < 0 instead of e.intersectionRatio < 1.

  4. The entire code is in JS -- I wish it'd been possible to do it all in CSS but that was not possible, so I took the next best path from my POV, which is implement it all in a single language, rather than have some part in JS and some in CSS.

I hope that helps.

Not the answer you're looking for? Browse other questions tagged or ask your own question.