Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-selectors] Proposal for allowing selectors that depend on layout (:stuck, :snap, :on-screen, etc) #5979

Open
argyleink opened this issue Feb 11, 2021 · 31 comments

Comments

@argyleink
Copy link
Contributor

argyleink commented Feb 11, 2021

Spec and WG writings:

Relevant proposals/issues:

Background:
Proposals like :snap and :stuck have been rejected because the layout could be changed on the matched element, thus undoing what was done, which then redoes the effect.. forever. This can be seen in :hover effects that change layout and cause the mouse to go out and in of the element as fast as the browser can draw it all. Not good. Reasonable reason to reject the ideas.

Proposal:
Layout dependent selectors can only be selector participants and never the subject.

/* wouldn't work, not allowed */
:stuck { 
  position: static; 
  box-shadow: 0 1ex 1ch hsl(0 0% 0% / 25%);
}

/* would work */
:stuck > nav { 
  position: static; 
  box-shadow: 0 1ex 1ch hsl(0 0% 0% / 25%);
} 

The cycle is heavily mitigated or entirely eliminated by not allowing the layout relevant selector to target the element with the desired layout pseudo class. We provide the hook, but prevent the hook from changing itself. Thoughts?

Conclusion:
Instead of attempting some list of approved styles for layout dependent pseudo classes, 1 rule could help prevent tons of footguns while simultaneously unlocking many new effects. The catch is that developers will need an extra node in the component so the layout effect and the styles are separated (aka: the element being stuck isn't what has the box-shadow).

Update 1

as suggested by Oriol

  1. pseudo-class cannot be part of a selector with + or ~
  2. pseudo-class cannot be mixed with :has()
@Loirooriol
Copy link
Contributor

I don't think this restriction is enough. Being able to select the contents means that you can affect the size of the sticky element through auto sizes, and thus whether it's stuck or not. See demo, which uses JS to crudely polyfill :stuck as .stuck, and only uses it in #sticky.stuck > *. It flickers since .stuck keeps toggling.

This circularity may be more obvious for similar proposals like :overflowed-content (#2011). Changing the contents can clearly affect whether the element has overflowing contents.

@argyleink
Copy link
Contributor Author

agree that :overflowed-content isn't a candidate here 👍🏻

demo makes sense, bottom: 0 was a good position to expose. this proposal definitely isn't bullet proof, but feels like you need to get creative to find a way to break it. thanks for sharing your creativity.

@Loirooriol
Copy link
Contributor

What about adding the additional constraint that :stuck can only match elements with size containment? I guess this would break the dependence on the contents.

@Loirooriol
Copy link
Contributor

Also, I guess that there should be a child or descendant combinator (or pseudo-element?) after :stuck. + or ~ are not enough because if :stuck can impact later siblings, they can affect the size of the containing block, which can affect whether the element is stuck. Alternatively, the containing block should also have size containment.

And mixing with :has() should be invalid, or :has(> :is(:stuck > *)) would bypass the restriction.

@argyleink
Copy link
Contributor Author

@Loirooriol interested in your thoughts regarding ways to make a child not snap given this proposal?

if :snap is only featured on children with scroll-snap-align: start, this proposal certainly prevents the snap-align value from being changed cyclically. Are there clever ways to change snapped items children contents insomuch they unsnap from the parent?

@Loirooriol
Copy link
Contributor

No idea, I know very little about snapping

@tabatkins
Copy link
Member

No, in fact the spec is pretty explicit about staying snapped to an element as the page mutates; only user action or unrecoverable mutations (like the snapped element being removed) should change the snap.

@argyleink
Copy link
Contributor Author

The containment requirement seems promising: containment spec quote

By itself, size containment does not offer much optimization opportunity. Its primary benefit on its own is that tools which want to lay out the containment box's contents based on the containment box's size (such as a JS library implementing the "container query" concept) can do so without fear of "infinite loops", where having a child’s size respond to the size of the containment box causes the containment box's size to change as well, possibly triggering further changes in how the child sizes itself and possibly thus more changes to the containment box's size, ad infinitum.

Sounds like this proposal (adding pseudo classes that can only be a selector member) in combination with contain: layout which "nothing outside can affect its internal layout, and vice versa" as suggested by Oriol, would:

  1. restrict all mutations to children
  2. restrict all children mutations to not affect the container box

Those conditions seem like they do remove the infinite potentials, complimenting each other. Creates an effect similar to how position: relative results, that it's original box is still there but it's off doin whatever. In this new case, given new current browser state, a box is styled different but without modifying the original box space / page layout. A stuck header could freely change height due to children changing and not cause an infinite loop.

This would also unlock :overflow as well?

@Loirooriol
Copy link
Contributor

I wonder if instead of a pseudo-class with some syntax restrictions, this could work with container queries? See #5989 "What container features can be queried?". Maybe whether the container is a sticky element in a stuck state?

If :overflow would mean that the element overflows its containing block, then yes, I think that limiting the effect to the descendants and requiring size containment could do the trick.

@wle8300
Copy link

wle8300 commented Mar 19, 2021

How about using JavaScript Events?

myStickyElement.addEventListener('stuck', function () {
  this.classList.add('is-stuck');
});
myStickyElement.addEventListener('unstuck', function () {
  this.classList.remove('is-stuck');
});

This was discussed in #1660 (comment) back in 2017, and it's still open

@SebastianZ
Copy link
Contributor

How about using JavaScript Events?

Later in that thread, @upsuper pointed out that events are working synchronously and would therefore slow down the layout. The suggestion back then was to use something asynchronous like an Observer.

Anyway, the discussion here is about a pure CSS way to handle those use cases. And the proposal @argyleink and @Loirooriol came up with sounds very promising to me so far. Also, it is orthogonal to any solution that involves JavaScript, i.e. they don't exclude each other.

Sebastian

@wle8300
Copy link

wle8300 commented Mar 20, 2021

@SebastianZ I see. Thx for the simple explanation!

@LeaVerou
Copy link
Member

Very clever idea, indeed this addresses most use cases!

However, do note that this is based on the fact that compound selectors before the subject can never target the same element as the subject. This is true today, but may not be true in the future. If we start adding pseudo-classes that avoid circularity by depending on this fact, it could severely restrict the types of new combinators we can add.

For example, there have been discussions about a preceding sibling combinator possibly being implementable (i.e. the opposite of +, let's call it <+ for the purposes of this discussion). This would re-introduce circularity, as you could do .foo:stuck + .bar <+ .foo.

Also, remember the reference combinator? Among other things, it allowed authors to target labels of elements, i.e. label /for/ input. Depending on the placement of the label, you could do input:stuck + label /for/ input to apply the pseudo-class to the input itself. It was dropped due to lack of implementor interest, but this would prevent it and anything like it from ever happening.

We may well decide that the value these pseudo-classes add outweighs the value of combinators that could go backwards. However, it should be a conscious decision to accept that tradeoff, and we should first explore variations of this proposal that don't force us into adding this restriction.

@SebastianZ
Copy link
Contributor

To handle that case it could be defined that the layout depending selector excludes the element from being the subject. In other words, .foo:stuck + .bar <+ .foo or input:stuck + label /for/ input would not match the stuck elements.

Sebastian

@LeaVerou
Copy link
Member

@SebastianZ Excludes what element from being the subject? I like where this is going, but it's a little hand-wavy at the moment.

@SebastianZ
Copy link
Contributor

The elements that are in a layout dependent state, meaning those that would be matched by the layout depending selector, i.e. :stuck, :snap, :on-screen, etc.

Taking your example .foo:stuck + .bar <+ .foo.

As UAs match from right to left, this would be:

  1. All elements with the class foo.
  2. Restricted to those that have a following sibling with class bar.
  3. Restricted to those that have a previous sibling that is stuck and has a class foo.
  4. Restricted to those that are not stuck.

So the layout depending selectors need to be remembered and the elements matching them have to be excluded in the last step. This last step is of course only needed in situations where the subject could be part of the compound selectors before it. As those are presumably special cases, it wouldn't be needed in most situations.

Sebastian

@tabatkins
Copy link
Member

Talked with @argyleink, @una, and @flackr today about this again.

While the cyclic-ness is somewhat problematic, all the attempts to get around it are doing more harm to the proposal than good, I believe. Plus, @scroll-timeline also has closely-related issues. Ultimately, it's tight cycles (within the layout engine) that are a strict no-go; we want to avoid cycles that are loose enough to span a painting frame, like what :hover does today, but they're just annoying but not killer.

So, I think we can deal with this entire bag of scrolling-related pseudos and styling by saying, essentially, that scrolling is snapshotted at the start of styling, stuck-ness or snapped-ness (or progress on a scroll timeline) is determined at that time, and then selectors are matched and styling is applied. If you do something that causes a stuck/snapped element to become unstuck/snapped, it'll show up next frame, but not affect selector resolution this frame. (To avoid a "flash of unsnapped content" effect, we can probably specify that the first frame does a style to figure out what is possible to stick/snap, then does another style to resolve the pseudos appropriately.)

At worst, then, you can produce a :hover-equivalent cycle, which is annoying but something we can deal with. The vast majority of ordinary usage will work exactly as expected with no problems, just as usage of :hover is almost always okay today.

@flackr believes this is fine implementation-wise. Thoughts?

@emilio
Copy link
Collaborator

emilio commented Aug 16, 2021

I don't know, hover cycles are annoying but ultimately they can be worked-around by the user by moving their mouse somewhere else. Some of the cycles we're talking about here might not have any reasonable workaround / way for the user to see the content if the page is flickering.

@tabatkins
Copy link
Member

Yeah, that's right. But I think it's also relatively hard to trigger on accident for these cases, and I think attempting to avoid it entirely results in cures worse than the disease.

@emilio
Copy link
Collaborator

emilio commented Aug 17, 2021

Yeah, that's right. But I think it's also relatively hard to trigger on accident for these cases, and I think attempting to avoid it entirely results in cures worse than the disease.

I'm not sure I agree, most of the work I had to do to implement scroll anchoring in Firefox involved diagnosing and fixing similar issues, and they are definitely not hard to trigger, see all the heuristics the scroll anchoring spec has to avoid them.

@emilio
Copy link
Collaborator

emilio commented Aug 17, 2021

(That said, happy to be proven wrong of course :))

@mirisuzanne
Copy link
Contributor

Quick note that I've started working on these features as part of container queries (see #5989). If this selector approach is viable, of course, it's certainly a less restrictive path for authors. I don't think there is any reason we need both, right? If we do create the selector, then the query would be redundant? I guess this is a "subscribe" comment, to keep an eye on this conversation before going too deep on the other.

@argyleink
Copy link
Contributor Author

I'm def excited about the container queries approach to some of these selectors! I have though started ideating a spec for :snap in the form of :snapped-target (and friends) over here. Would love feedback. It in particular doesn't feel as connected to the container as the others. Open for discussion!

colons added a commit to very-scary-scenario/nkd.su that referenced this issue Oct 13, 2021
This isn't super graceful, but most users can't even select enough
tracks that this'd be an issue in most cases.

I guess keep an eye on w3c/csswg-drafts#5979
for a potentially more graceful solution.
@alexander-schranz
Copy link

I wanted to add the following link here from the Chrome Developers Blog as it did help me to current workaround this issue about the missing :stuck selector: https://developer.chrome.com/blog/sticky-headers/. In combination with CustomStateSet API which is sadly not yet supported by firefox it even could a :--stuck be achieved.

@Link2Twenty
Copy link

Link2Twenty commented Jul 19, 2022

Am I correct in my assumption that this is the style of syntax suggested if we go the container query route?

.header {
  position: sticky;
  top: 0;
}

.header__content {
  font-family: sans-serif;
  padding: 0.6rem 1rem;
}

@container state(stuck) {
  .header__content {
    background-color: teal;
    color: white;
  }
}
<header class="header">
  <div class="header__content">Page Title</div>
</header>
<main>
  ...
</main>

If so it feels like a good compromise and opens up the potential for lots of states to exist on a container (for better or worse).

@yisibl
Copy link
Contributor

yisibl commented Aug 24, 2023

@trusktr
Copy link

trusktr commented Apr 17, 2024

I wanted :stuck { /* ... box shadow ... */ } for position:sticky content, then Google led me here. It would be great to be able to do this.

@mirisuzanne
Copy link
Contributor

mirisuzanne commented Apr 17, 2024

@lilles What is the status of your state queries explainer/proposal and prototype? Is that something we could try to get agreement on, and begin to specify?

(I am querying the status of a state query)

@argyleink
Copy link
Contributor Author

in Canary (with cli flag CSSStickyContainerQueries) you can prototype adding a shadow with position sticky, it looks like this:

dt {
  container-type: scroll-state;
  position: sticky;

  > header {
    transition: box-shadow .5s ease;
    
    @container scroll-state(stuck: top) {
      box-shadow: 0 5px 5px #0003;
    }
  }
}

Codepen proto

@mirisuzanne
Copy link
Contributor

It would be great to get these behind a UI flag, for author testing.

@lilles
Copy link
Member

lilles commented Apr 19, 2024

@lilles What is the status of your state queries explainer/proposal and prototype? Is that something we could try to get agreement on, and begin to specify?

Yes, we resolved to create css-contain-4 and add scroll-state(), so we can start. I've just been very busy with anchor pos the last months. My next step is to finish the 'snapped' prototype.

(I am querying the status of a state query)

After the resolution in #6402 (comment) I moved the explainer to and renamed the function to scroll-state().

Reducing container-type to a single one (scroll-state) was discussed, but not resolved. But since I changed it in the prototype I should reflect that in the explainer.

It would be great to get these behind a UI flag, for author testing.

Sticky queries are enabled if you enable chrome://flags/#enable-experimental-web-platform-features

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests