Targeting position:sticky elements that are currently in a 'stuck' state
Asked Answered
D

7

216

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.

Disannul answered 14/8, 2014 at 13:8 Comment(0)
V
169

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.

Vastitude answered 14/8, 2014 at 15:51 Comment(21)
Unfortunately @Vastitude is right, it won't be native for a long long time. Example hacks around these could be found at: yahoo mail (scroll a mail's content, they have a nice trick) and google dashboard (uses absolute shadow element).Drumhead
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))Isahella
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)...Peepul
See github.com/w3c/csswg-drafts/issues/1660 where the proposal is to have JS events to know when something becomes stuck/unstuck. That should not have the issues that a pseudo-selector introduces.Celandine
@Ruben: I'm all for that.Vastitude
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.Miscreance
@Marek Lisý: Yes, the reasoning given by browser vendors is that they don't want to make the same mistake. I agree that the author should be responsible, but vendors do still have to guard against author mistakes.Vastitude
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 ;)Miscreance
@LazarLjubenović Here is a blog post with a hack that you can use in the interim: developers.google.com/web/updates/2017/09/sticky-headersCelandine
I highly advise against the hack above. The global support for IntersectionObserver is low. If you don't have nested scrolling, you're better off using getClientBoundingRect() and scroll listening.Waxler
if ie4 could support :hover{display:none;} i'm sure modern browsers will not explode with :stuck{position:static;}Ismaelisman
@oriadam: Have you actually seen what :hover{display:none;} looks like? On most browsers elements just flicker in and out of existence as fast as your display can refresh. And the rest of the layout gets affected as it happens. I don't think browsers want to introduce more situations like that.Vastitude
@Vastitude Yes, it looks buggy. The possibility to create a buggy behavior with an elaborated technique is not a reason to disable a (much needed) feature all together. For example, being able to enter <div> (instead of <li>) inside <ul> is not a reason to discard lists all together, is it?Ismaelisman
@oriadam: Bad markup and cyclic dependencies in CSS are two different classes of problems entirely.Vastitude
@Vastitude It's just a parable. Here's another one - should we cancel loops in JS because one can do endless loop? The element would flicker like crazy when doing :stuck{position:static} - so be it. It shouldn't happen anyway, people just want the :stuck to set box-shadow or change some element by checking .is(':stuck') on scroll event.Ismaelisman
The endless loop example that both @Ismaelisman and MarekLisý have mentioned seems like a good comparison to me. I don't understand why CSS needs to prevent devs from writing bad code. Is there some technical reason that makes it either impossible or prohibitively expensive to implement such a feature? The fact that devs might make mistakes with it surely can't be the reason.Fricandeau
can :stuck just ignore positional properties? mostly it will just be used to add a freakin shadow anyway...Ismaelisman
I've added an answer below, which includes a pure CSS hack that simulates ::stuck.Hurt
So they still didn't do it in 2022? maan.. It should be so simple. And i agree with those saying these kind of problems can be found with very many programming features.Figureground
There are lots of cases where CSS can have circularity, like where :hover will move something, causing it to no longer be hovered, etc. That's fine: it causes visual glitches and the developer fixes it. It's not a convincing reason to not have important, obvious features, like when you want a button to only appear on the sticky header and not repeat in every visible header.Gadhelic
How hard can it be: if :stuck then ignore(list of attributes). Definitions of flexbox item attributes get ignored when a parent element isn't a flexbox container, nothing new there. Not being able to check the current state of an element (stuck, transformed, inviewport, to name a few) in CSS is a HUGE omission. Besides that, I'd say: let developers worry about circular calls, not the specs. They've been doing that since 'programming' was invented and gotten wiser in the process.Halleyhalli
I
82

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

Insincerity answered 20/6, 2019 at 2:26 Comment(11)
I think that a stuck class would be better than a custom attribute... Is there any specific reason for your choice?Maplemaples
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.Insincerity
I need my top to be 60px because of an already fixed header, so I can't get your example to workDesrosiers
Try adding some top padding to the to whatever is being stuck, maybe padding-top: 60px in your case :)Meredith
use rootMargin:'-61px 0px 90px 0px' as option for the observer. Where the -61 makes the top margin for the intersectionRect smaller. the 90px pushes the boundary outsite of the instersectionRect preventing the stuck attribute whenever you scroll past the element.Kaunas
This works well, but worth noting that IntersectionObserver is an experimental tech and isn't supported by IE: developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/…Fireball
It's just not a matter of taste –— to add invalid HTML5 attributes to elements. I would highly suggest you revise and acknowledge @Maplemaples 's comment.Weeping
To piggyback on the GREAT code + comments, to make this work as @Desrosiers suggested, the correct options are: rootMargin: '-' + YOUR_OFFSET + 1 + 'px 0px 0px 0px', threshold: [1] and in my case the div was not initially on the page so my callback logic was adjusted to e.intersectionRatio !== 0 && e.intersectionRatio < 1. Basically your looking for a value in between 0 and 1.Perspiration
The idea of using IntersectionObserver is great! Will read the MDN docs about it now and wanted to thank you for this great inspiration.Channel
If you are sticking (no pun intended) with the attribute approach, you should be using data-* attributes. Because "The data-* global attributes form a class of attributes called custom data attributes, that allow proprietary information to be exchanged between the HTML and its DOM representation by scripts." sourceEnclose
css-tricks tutorial: css-tricks.com/how-to-detect-when-a-sticky-element-gets-pinnedHuebner
H
29

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;
}
Hurt answered 12/3, 2021 at 7:22 Comment(2)
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 thoughIr
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 :)Ecospecies
C
8

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.

Cashbook answered 16/1, 2019 at 12:47 Comment(0)
W
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;
    });
})
Waxler answered 29/9, 2018 at 1:19 Comment(2)
Note that scroll event listener is executed on the main thread which makes it a performance killer. Use the Intersection Observer API instead.Gymnasium
if (requestedFrame) { return; } It's not a "performance killer" due to the animation frame batching. Intersection Observer is still an improvement though.Waxler
E
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'))
Epistyle answered 13/6, 2020 at 12:21 Comment(0)
M
1

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.

Mach answered 27/2 at 15:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.