|
| 1 | +# CSS Scroll State Container Queries Explainer |
| 2 | + |
| 3 | +## Introduction |
| 4 | + |
| 5 | +Level 3 of CSS Containment introduces Container Queries for querying size and |
| 6 | +style of containers to style their descendants depending on layout and computed |
| 7 | +styles respectively. There are requests to allow to query other states |
| 8 | +associated with a container, in particular various scroll based states. |
| 9 | + |
| 10 | +## Background and Motivation |
| 11 | + |
| 12 | +Certain scroll-based states are only available for styling through javascript |
| 13 | +events. These include whether a sticky positioned element is stuck in the sense |
| 14 | +that it has an position offset applied, whether a scroll-snap-aligned element |
| 15 | +is snapped to its scroll snap container or not. Exposing these states in CSS |
| 16 | +would make it simpler for authors to create effects on snapped elements, or |
| 17 | +have sticky positioned elements change their appearance when stuck. |
| 18 | + |
| 19 | +This article talks about the need for support for styling scroll-snapped |
| 20 | +elements: |
| 21 | +[https://web.dev/state-of-css-2022/#scroll-snap-features-are-too-limited](https://web.dev/state-of-css-2022/#scroll-snap-features-are-too-limited). |
| 22 | +In particular, see: [https://web.dev/state-of-css-2022/#snap-target](https://web.dev/state-of-css-2022/#snap-target). |
| 23 | +This article also talks about the lack of being able to style snapped elements, |
| 24 | +using intersection observers as a javascript alternative: |
| 25 | +[https://blog.logrocket.com/style-scroll-snap-points-css/](https://blog.logrocket.com/style-scroll-snap-points-css/). |
| 26 | + |
| 27 | +These ideas have been explored before. For instance, see |
| 28 | +[this github issue](https://github.com/w3c/csswg-drafts/issues/5979), and |
| 29 | +[this explainer](https://github.com/argyleink/ScrollSnapExplainers/blob/main/css-snap-target/readme.md) |
| 30 | +for `:snapped` pseudo classes. That explainer pre-dates the container queries |
| 31 | +specification and implementation that has happened recently. |
| 32 | + |
| 33 | +One major argument against exposing this state to style resolution is that it |
| 34 | +introduces cycles in rendering updates since layout influences scroll positions |
| 35 | +which influence this state which again can influence layout through style |
| 36 | +depending on this state (see |
| 37 | +[this comment](https://github.com/w3c/csswg-drafts/issues/5979#issuecomment-899765136) |
| 38 | +and onwards). |
| 39 | + |
| 40 | +There is an [open github issue](https://github.com/w3c/csswg-drafts/issues/6402) |
| 41 | +for state container queries where it was resolved to defer to the next level of |
| 42 | +the spec - presumably css-contain-4. |
| 43 | + |
| 44 | +## Container Queries vs Pseudo Classes |
| 45 | + |
| 46 | +Container queries are an alternative to using pseudo classes for these states. |
| 47 | +There are pros and cons of using new pseudo classes. Container queries would |
| 48 | +naturally limit the style depending on this state to be applied in the stuck or |
| 49 | +snapped element's subtree, while a pseudo class could arbitrarily be used to |
| 50 | +style sibling subtrees and even ancestors (using `:has()`) unless the use in |
| 51 | +selectors are restricted to certain combinators. One can argue that using |
| 52 | +pseudo classes would make it more likely to end up in situations where layout |
| 53 | +cycles are an issue. |
| 54 | + |
| 55 | +Containers already have the container-type property which can be used to |
| 56 | +explicitly choose which elements can be queried for state, which could also |
| 57 | +help avoiding cycles issues. It would also allow for more performant |
| 58 | +implementations in that fewer elements need to be checked for state. When size |
| 59 | +containers were introduced, there was a conscious choice to introduce an |
| 60 | +explicit container-type instead of implicitly deduce a size container from the |
| 61 | +`contain` property. For a pseudo class approach, we would either have to |
| 62 | +introduce an explicit state property or deduce that an element is allowed to |
| 63 | +match based on another property. E.g. `:stuck` allowed for `position:sticky`. |
| 64 | + |
| 65 | +In the discussion in: |
| 66 | +[https://github.com/w3c/csswg-drafts/issues/5979](https://github.com/w3c/csswg-drafts/issues/5979), |
| 67 | +container queries are brought up as an alternative to pseudo classes. That |
| 68 | +issue is also talking about containment, whether container queries should be |
| 69 | +used instead of pseudo classes, and the fact that the need for an extra loop |
| 70 | +after snapshotting could be hooked into the same HTML rendering update as for |
| 71 | +scroll driven animations. |
| 72 | + |
| 73 | +## States to Query |
| 74 | + |
| 75 | +This explainer tries to capture scroll-based state queries and the following |
| 76 | +sections cover each of them. The commonality is that they all rely on scroll |
| 77 | +positions and sizes. |
| 78 | + |
| 79 | +### Stuck Sticky-positioned Elements |
| 80 | + |
| 81 | +Introduce a container-type `sticky` to allow sticky positioned elements as |
| 82 | +query containers for querying whether the sticky positioned has an offset |
| 83 | +applied to fulfill the constraint for a given inset property. For instance: |
| 84 | + |
| 85 | +```html |
| 86 | +<style> |
| 87 | +#sticky { |
| 88 | + container-name: my-menu; |
| 89 | + container-type: sticky; |
| 90 | + position: sticky; |
| 91 | + top: 0px; |
| 92 | + height: 100px; |
| 93 | +} |
| 94 | +
|
| 95 | +#sticky-child { |
| 96 | + background-color: orange; |
| 97 | + color: white; |
| 98 | + height: 100%; |
| 99 | +} |
| 100 | +
|
| 101 | +@container my-menu scroll-state(stuck: top) { |
| 102 | + #sticky-child { width: 50%; } |
| 103 | +} |
| 104 | +</style> |
| 105 | +<div id="sticky"> |
| 106 | + <div id="sticky-child"> |
| 107 | + Sticky |
| 108 | + </div> |
| 109 | +</div> |
| 110 | +``` |
| 111 | + |
| 112 | +There is a question whether the query should match when the applied offset is |
| 113 | +still 0, but it is exactly aligned with the edge of where an offset would start |
| 114 | +to take effect. |
| 115 | + |
| 116 | +#### Use Cases and Author Requests |
| 117 | + |
| 118 | +- Demo that changes class through javascript based on the scrollTop value: |
| 119 | + [Header (Logo pops in)](https://codepen.io/JGallardo/pen/ZEBbeP) |
| 120 | +- [Article using IntersectionObserver](https://davidwalsh.name/detect-sticky), |
| 121 | + but stating there should ideally be a :stuck pseudo |
| 122 | +- [Using IntersectionObserver to fire custom sticky-change events](https://developer.chrome.com/blog/sticky-headers/) |
| 123 | +- [Another demo using IntersectionObserver](https://codepen.io/bhupendra1011/pen/GRKxWMM) |
| 124 | + |
| 125 | +#### Chrome Prototype Demo |
| 126 | + |
| 127 | +<video width="400px" src="https://lilles.github.io/explainers/sticky.mov" controls muted></video> |
| 128 | + |
| 129 | +### Scroll-snapped Elements |
| 130 | + |
| 131 | +Introduce a container-type `snapped` to allow scroll-snap-aligned elements to |
| 132 | +be queried for whether they are currently |
| 133 | +[snapped](https://drafts.csswg.org/css-scroll-snap/#scroll-snap) in a given |
| 134 | +direction. For instance: |
| 135 | + |
| 136 | +```css |
| 137 | +@container scroll-state(snapped: block) { |
| 138 | + #snap-child { |
| 139 | + outline: 5px solid yellow; |
| 140 | + } |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +#### Use Cases and Author Requests |
| 145 | + |
| 146 | +In general being able to highlight the scroll snapped element, for instance in |
| 147 | +a carousel. |
| 148 | + |
| 149 | +- [Author request on github](https://github.com/w3c/csswg-drafts/issues/7430) |
| 150 | + |
| 151 | +### Overflowing |
| 152 | + |
| 153 | +Query whether a container has scrollable overflow. Can be used to indicate |
| 154 | +there is content to scroll to in a given direction. |
| 155 | + |
| 156 | +Needs further exploration. |
| 157 | + |
| 158 | +### Anchor position fallback |
| 159 | + |
| 160 | +In earlier exploration of anchor positioning, it was suggested that container |
| 161 | +queries could be used to style the contents of an anchor positioned element |
| 162 | +based on which [`@position-fallback`](https://drafts.csswg.org/css-anchor-position-1/#fallback-rule) |
| 163 | +is used. |
| 164 | + |
| 165 | +This needs further exploration. |
| 166 | + |
| 167 | +## Proposed Syntax |
| 168 | + |
| 169 | +Add a new scroll-state() function to `@container` similar to `style()` where |
| 170 | +`state:value` pairs, and logical combinations of them, can be queried. |
| 171 | +`<state-feature>` will be similar to |
| 172 | +[`<style-feature>`](https://drafts.csswg.org/css-contain-3/#typedef-style-feature) |
| 173 | +in that it takes a state name and value separated by a colon. |
| 174 | + |
| 175 | +Add a new [`container-type`](https://drafts.csswg.org/css-contain-3/#container-type) |
| 176 | +for each state which can be combined with size container types: |
| 177 | + |
| 178 | +- `stuck` |
| 179 | +- `snapped` |
| 180 | + |
| 181 | +Query values for `stuck`: |
| 182 | + |
| 183 | +- `none` |
| 184 | +- `top` |
| 185 | +- `left` |
| 186 | +- `right` |
| 187 | +- `bottom` |
| 188 | +- `inset-block-start` |
| 189 | +- `inset-block-end` |
| 190 | +- `inset-inline-start` |
| 191 | +- `inset-inline-end` |
| 192 | + |
| 193 | +Query values for `snapped`: |
| 194 | + |
| 195 | +- `none` |
| 196 | +- `block` |
| 197 | +- `inline` |
| 198 | + |
| 199 | +A query with only a state name (boolean context) would match if the state is |
| 200 | +different from `none`. |
| 201 | + |
| 202 | +## Containment Requirements |
| 203 | + |
| 204 | +The proposed approach to handling layout cycles is to have a two-pass rendering |
| 205 | +update if necessary like scroll-driven animations have. See the |
| 206 | +[Layout Cycles](#layout-cycles) section below for details. There is strictly no |
| 207 | +need to have containment applied to the new container-types to have a |
| 208 | +predictable rendering, but it would help authors to avoid flickering issues. |
| 209 | +The question is whether it makes sense to enforce containment if containment is |
| 210 | +not enough to fix all flickering issues. |
| 211 | + |
| 212 | +By flickering issues we mean cases where the stuck styling would cause the |
| 213 | +sticky position to no longer be stuck and the following rendering update would |
| 214 | +lose that style and cause it to be stuck again. Similarly a scroll-snapped |
| 215 | +element could be made no longer snapped through style changes affecting layout |
| 216 | +and scroll position. |
| 217 | + |
| 218 | +## Layout Cycles |
| 219 | + |
| 220 | +The scroll based state queries have the same issue with potential layout cycles |
| 221 | +that scroll-driven animations have. Whether an element has a sticky position |
| 222 | +offset or is scroll-snapped is not known until after layout, and state queries |
| 223 | +affect styles inside the containers which may affect whether the sticky element |
| 224 | +has a sticky position offset or not. |
| 225 | + |
| 226 | +It is possible that a lot of these cycles could be avoided if enough |
| 227 | +containment is applied, but that would not allow for inline-size containment |
| 228 | +(require full size containment), and also nesting sticky positioned elements |
| 229 | +would be a problematic case. |
| 230 | + |
| 231 | +More importantly, there is the problem of applying scroll based state queries |
| 232 | +on the first frame, which regardless requires finishing layout before the query |
| 233 | +containers' state can be found which would require another pass regardless of |
| 234 | +containment requirements. |
| 235 | + |
| 236 | +The scroll-driven animations spec specifies a set of stale timelines which |
| 237 | +makes it possible to apply the current timeline based on newly created |
| 238 | +timelines, or timelines that changed because of layout changes, to the same |
| 239 | +frame that the timeline was created or changed. It is however limited to a |
| 240 | +single extra update, specified as |
| 241 | +[modifications to the HTML event loop](https://drafts.csswg.org/scroll-animations/#html-processing-model-event-loop). |
| 242 | + |
| 243 | +It should be possible to do scroll based state queries using the exact same |
| 244 | +timing for updating state query state and detect if there is a need for a |
| 245 | +second pass to update the rendering based on the updated state. |
| 246 | + |
| 247 | +## Transitions |
| 248 | + |
| 249 | +The two-pass rendering update introduces a side-effect of starting transitions |
| 250 | +which can be unfortunate, especially for the first rendered frame. This is |
| 251 | +already an observable effect for scroll-driven animations, which can be seen |
| 252 | +in the example below. |
| 253 | + |
| 254 | +```html |
| 255 | +<!DOCTYPE html> |
| 256 | +<style> |
| 257 | + #timeline { |
| 258 | + scroll-timeline: --scroll y; |
| 259 | + width: 400px; |
| 260 | + height: 400px; |
| 261 | + overflow-y: scroll; |
| 262 | + } |
| 263 | + @keyframes w { |
| 264 | + 0% { |
| 265 | + width: 300px; |
| 266 | + } |
| 267 | + 100% { |
| 268 | + width: 400px; |
| 269 | + } |
| 270 | + } |
| 271 | + #container { |
| 272 | + container-type: inline-size; |
| 273 | + position: fixed; |
| 274 | + animation-timeline: --scroll; |
| 275 | + animation-name: w; |
| 276 | + width: 200px; |
| 277 | + height: 200px; |
| 278 | + } |
| 279 | + #target { |
| 280 | + height: 100%; |
| 281 | + background-color: green; |
| 282 | + transition: background-color 4s; |
| 283 | + } |
| 284 | + #target { background-color: green; } |
| 285 | + @container (width > 200px) { |
| 286 | + #target { background-color: lime; } |
| 287 | + } |
| 288 | +</style> |
| 289 | +<div id="timeline"> |
| 290 | + <div id="container"> |
| 291 | + <div id="target"></div> |
| 292 | + </div> |
| 293 | + <div style="height:2000px"></div> |
| 294 | +</div> |
| 295 | +``` |
0 commit comments