Skip to content

Add guide and evals for context-sensitive sticky headers#603

Open
patrickkettner wants to merge 6 commits into
mainfrom
sticky-headers-guide
Open

Add guide and evals for context-sensitive sticky headers#603
patrickkettner wants to merge 6 commits into
mainfrom
sticky-headers-guide

Conversation

@patrickkettner
Copy link
Copy Markdown
Contributor

Fixes #256

@patrickkettner patrickkettner force-pushed the sticky-headers-guide branch 3 times, most recently from a52b78f to 3112e18 Compare April 18, 2026 18:44
@rviscomi
Copy link
Copy Markdown
Member

Results of local gd eval run:

Pass Rate - Unguided: 0%, Guided: 0%
{
  "summary": {
    "unguidedMedian": 0,
    "guidedMedian": 0,
    "unguidedPassRate": 0,
    "guidedPassRate": 0,
    "unguidedPassed": 0,
    "unguidedTotal": 0,
    "guidedPassed": 0,
    "guidedTotal": 0,
    "runsPerTest": 1,
    "expectedTotalRuns": 1,
    "taskCount": 1,
    "runCountPerTask": 1,
    "guideUsageRate": 0,
    "guideUsageCount": 0,
    "totalGuidedRuns": 1,
    "totalGuidedNonDisciplineRuns": 1,
    "toolActivationRate": 0,
    "toolActivationCount": 0,
    "unguidedEarlyFailures": 0,
    "unguidedEarlyFailureRate": 0,
    "guidedEarlyFailures": 0,
    "guidedEarlyFailureRate": 0,
    "guidedNonDisciplineEarlyFailures": 0
  },
  "results": {
    "task - context-sensitive-sticky-headers - guided": [
      {
        "runNumber": 1,
        "results": [],
        "guidesUsed": [],
        "retrievedGuides": [],
        "fileReadGuides": [],
        "guidanceToolsUsed": [],
        "discipline": "user-experience",
        "isSkill": false,
        "expectedToolPrefixes": [
          "modern-web"
        ],
        "guideName": "context-sensitive-sticky-headers",
        "taskName": "task",
        "baseApp": "daily-grind",
        "prompt": "Add new section headers inside the main content area of the page (e.g., within the cards or sections). These headers should stick to the top when scrolling."
      }
    ],
    "task - context-sensitive-sticky-headers - unguided": [
      {
        "runNumber": 1,
        "results": [],
        "guidesUsed": [],
        "retrievedGuides": [],
        "fileReadGuides": [],
        "guidanceToolsUsed": [],
        "discipline": "user-experience",
        "isSkill": false,
        "expectedToolPrefixes": [
          "modern-web"
        ],
        "guideName": "context-sensitive-sticky-headers",
        "taskName": "task",
        "baseApp": "daily-grind",
        "prompt": "Add new section headers inside the main content area of the page (e.g., within the cards or sections). These headers should stick to the top when scrolling."
      }
    ]
  },
  "stats": {
    "task - context-sensitive-sticky-headers - unguided": {
      "medianPassRate": 0,
      "runPassRates": [
        0
      ],
      "runCount": 1,
      "isSkill": false,
      "passedChecks": 0,
      "totalChecks": 0,
      "earlyFailures": 0
    },
    "task - context-sensitive-sticky-headers - guided": {
      "medianPassRate": 0,
      "runPassRates": [
        0
      ],
      "runsUsingGuide": 0,
      "runsWithToolActivation": 0,
      "runCount": 1,
      "isSkill": false,
      "passedChecks": 0,
      "totalChecks": 0,
      "earlyFailures": 0
    }
  },
  "timestamp": "2026-04-22T19:40:06.347Z",
  "runCount": 1,
  "agent": "gemini_cli",
  "serving": "skills_cli",
  "model": "gemini-pro-latest"
}

Given the 0% guided pass rate, this use case would require some additional investigation and fine tuning. Let's remove the eval files from this PR to unblock getting the guidance in and eng will follow up on the evals separately.

@rviscomi
Copy link
Copy Markdown
Member

rviscomi commented May 1, 2026

@LeaVerou are you able to take a look at this one?

@rviscomi rviscomi requested a review from LeaVerou May 1, 2026 17:02
@LeaVerou
Copy link
Copy Markdown
Collaborator

LeaVerou commented May 1, 2026

@LeaVerou are you able to take a look at this one?

Just saw this, will take a look in a bit!

@LeaVerou
Copy link
Copy Markdown
Collaborator

LeaVerou commented May 4, 2026

SME Review

P0: Flashing when slightly scrolled

The guide downplays the problem quite a bit:

Changing the stuck element's box size (padding, height, font-size) is a common and valid pattern. Be aware that the browser performs a two-pass rendering update to resolve scroll-state queries, so a size change on the stuck state may produce a one-frame settle. Using transition on the changing properties smooths this visually. Avoid changes large enough to un-stick the element (e.g., collapsing to height: 0), which would cause the query to oscillate.

This is what the demo page looks like if the container is only slightly scrolled:

Screen.Recording.2026-05-01.at.20.46.43.mov

It gets way worse if you also reduce the font-size (as is often needed). Sometimes the CSS will fight against the scrolling even:

Screen.Recording.2026-05-04.at.09.24.05.mov

And even worse if you move the transition inside the CQ (so that it only applies when you go from unstuck → stuck but NOT when you go stuck → unstuck):

Screen.Recording.2026-05-04.at.09.56.16.mov

You can trigger the issue reliably by adding this snippet to the demo and playing with offsetY, though to experience it in its full glory you need to actually scroll slowly yourself:

<script>
let firstHeader = document.querySelector(".section > .sticky-container");
firstHeader.scrollIntoView({behavior: "smooth"});
setTimeout(() => {
	// Adjust this number for different versions of the problem. Any number between 1-43 seems to trigger it.
	const offsetY = 10;
	document.scrollingElement.scrollTop += offsetY;
}, 1000);
</script>

Avoiding "large enough" changes does not fix this, it just reduces the interval where this is observable. I could not find any box-model affecting change where the problem was not present at all. Even going from font-size: 100% to font-size: 99% triggered it!

I did end up getting badly nerd sniped by this and doing original research to come up with workarounds 😅, which I'll publish in a separate blog post (I can drop a link here when it's out if it's of interest), but so far all workarounds I found have their flaws.

The best one so far is adding an ::after in the stuck state to counteract the height increase so that .sticky-container does not reduce in height, but then you get empty space you have to scroll past to get the element "unstuck":

Screen.Recording.2026-05-04.at.09.30.14.mov

For this particular case, a min-height on .sticky-container would also work equally well, with the same downside:

Screen.Recording.2026-05-04.at.09.34.14.mov

But a min-height doesn't generalize well: usually your headings have varying content → different height in different viewports, and if you undershoot even by a bit, the flickering comes back.

In both cases you'd want pointer-events: none on .sticky-container and pointer-events: auto on .sticky-header so that the extra height doesn't swallow pointer events.

But we probably want to be recommending tried and tested techniques for this, not brand new workarounds… So, I'm not quite sure what's the best way forwards. 😞

P2: DO always specify container name

Given that these CQs may be nested, we should emphasize that these queries should always include a container name.

P2: Not sure what "context-sensitive" refers to

I don't see anything context sensitive about this? The description doesn't help either:

description: Build sticky section headers or navbars that visually transform when they're actually "stuck" at the top, collapsing, changing their color scheme, gaining a shadow, or switching to a more compact layout.

Fallback strategies

P0: No fallback in the demo page

The fallback is not present in the demo page. It should not only be there, but it should be verified that it's there via the expectations!

P1: Discuss PE as the default and frame the IntersectionObserver fallback

Currently, the guidance recommends an IntersectionObserver workaround if these CQs are not supported. However, in many cases, the styling or even the sticking altogether is a nice-to-have, and would not be worth this. E.g. if you're using this to stick the current heading to provide the user some additional context cue (like the demo), IMO this falls squarely under progressive enhancement.

If the default styling works and the scroll-state() query just improves things, perhaps no fallback is needed.

If the stuck styling is absolutely necessary to make the stuck content readable (e.g. adds a background that can't be there in the normal state or makes the element smaller when it would normally occupy too much space), perhaps it could be enough to just gate the position: sticky itself under a @supports (container-type: scroll-state) feature query.

I'd recommend the IntersectionObserver fallback only if the feature is critical, not by default.

P2: Make polyfill more generic

P2: This is one of these cases where the fidelity of the polyfill matters, since the feature is only supported in Chrome currently, and while technically nothing breaks when it's not there, it's also not a small difference.

Hardcoding it works if all you have is one example, but ideally, you don't want to have multiple instances of this with different values. You can instead detect these, for example:

  • use getComputedStyle() to read the actual value
  • Depending on the value, find the scroll parent that's scrollable in that direction by traversing up and comparing clientHeight with scrollHeight (or -Width), assume viewport if none found. In fact, this could be part of a scroll-state(scrollable) polyfill too…

P2: Fallback duplication

As you will see once the fallback is actually added to the demo, there is no way to maintain the styles in one place, they need to be duplicated. Unfortunately, I have no good solution for this either, besides framing this as PE-first, as I recommended above.

Copy link
Copy Markdown
Collaborator

@LeaVerou LeaVerou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See review.

Comment thread guides/user-experience/context-sensitive-sticky-headers/guide.md Outdated
Comment thread guides/user-experience/context-sensitive-sticky-headers/guide.md Outdated
@patrickkettner patrickkettner requested a review from LeaVerou May 10, 2026 01:24
@patrickkettner
Copy link
Copy Markdown
Contributor Author

@LeaVerou would love to hear about the blog post once you post it!

@rviscomi redid the evals - let me know if you want them removed from this commit

@LeaVerou
Copy link
Copy Markdown
Collaborator

Just saw the review request — GitHub didn't bother notifying me for some reason. I'll try to take a look soon.

@LeaVerou would love to hear about the blog post once you post it!

I held off on it because after talking to @tabatkins he thinks it's either a browser bug or a spec bug, so I filed w3c/csswg-drafts#13898 for now so we can figure that out first.

Comment thread guides/user-experience/state-aware-sticky-headers/guide.md
### Notes on Dimension Changes

**MANDATORY:** **DO NOT change box-model properties (height, padding, border, font-size) when an element becomes stuck.**
Because the browser performs a two-pass rendering update to resolve scroll-state queries, modifying any property that affects layout (even changing `font-size` from `100%` to `99%`) can cause the element to immediately become unstuck. This results in an infinite layout loop and severe visual flickering when users scroll slowly near the intersection point (specifically, within a 1-43px offset window).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 1-43px offset window was for this specific case! I'm sure it varies per layout depending on the exact shrinkage.

(Also having to recommend against changing box-model properties is quite unfortunate, but also probably the right thing to do… :/)

Copy link
Copy Markdown
Contributor Author

@patrickkettner patrickkettner May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good news :D with overflow-anchor: none on the scroll container, the box-model restriction goes away.

**MANDATORY:** **DO NOT change box-model properties (height, padding, border, font-size) when an element becomes stuck.**
Because the browser performs a two-pass rendering update to resolve scroll-state queries, modifying any property that affects layout (even changing `font-size` from `100%` to `99%`) can cause the element to immediately become unstuck. This results in an infinite layout loop and severe visual flickering when users scroll slowly near the intersection point (specifically, within a 1-43px offset window).

Adding a `transition` does not fix this flashing; in fact, putting the `transition` inside the `@container` query makes it even worse. Only change non-layout affecting properties such as `background-color`, `color`, `opacity`, and `box-shadow`.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transform (and individual transform properties) too! E.g. see https://codepen.io/leaverou/pen/xbROqRo for an example on how it can be used to fake shrinking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(addressed by the rewrite)

with overflow-anchor: none in place, transforms work fine alongside box-model changes. The new section explicitly calls out transforms as one of the things you can now safely animate in the stuck state.

Comment thread guides/user-experience/state-aware-sticky-headers/guide.md Outdated
Copy link
Copy Markdown
Collaborator

@LeaVerou LeaVerou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments!

@patrickkettner
Copy link
Copy Markdown
Contributor Author

I held off on it because after talking to @tabatkins he thinks it's either a browser bug or a spec bug, so I filed w3c/csswg-drafts#13898 for now so we can figure that out first.

Looks like overflow-anchor: none lands this for us. Should I update the guide to recommend that on the scroll container instead of the broad "no box-model changes" rule? Then I could reframe the flicker section around scroll anchoring as the cause. (Still keen on the blog post whenever it happens :D)

@LeaVerou
Copy link
Copy Markdown
Collaborator

I held off on it because after talking to @tabatkins he thinks it's either a browser bug or a spec bug, so I filed w3c/csswg-drafts#13898 for now so we can figure that out first.

Looks like overflow-anchor: none lands this for us. Should I update the guide to recommend that on the scroll container instead of the broad "no box-model changes" rule? Then I could reframe the flicker section around scroll anchoring as the cause. (Still keen on the blog post whenever it happens :D)

Yes!! I was unsure about it being applied to :root but it appears it works when applied to section too! https://codepen.io/leaverou/pen/JobKgJQ

It still worries me that it's disabling a supposedly positive behavior, and the lack of Safari support, but overall this does look very promising!

The issue is also slotted to be discussed tomorrow, so I might have more data by then.

@patrickkettner
Copy link
Copy Markdown
Contributor Author

awesome! Ill hold off on updating anything due to it until after I hear back

@LeaVerou
Copy link
Copy Markdown
Collaborator

awesome! Ill hold off on updating anything due to it until after I hear back

Bottom line was: we need to fix this problem in CSS, unsure how we'll do it.

I think recommending overflow-anchor: none on the parent of the stuck item (not the root) is fine for now.

Also, another point that @tabatkins brought up in the call was that if an element is bottom stuck, overflow-anchor would do nothing to fix the flickering. Perhaps we should recommend against adjusting box model on elements that are stuck to the bottom? Though these are so rare, I'm not sure it's worth it.

@rviscomi
Copy link
Copy Markdown
Member

@LeaVerou are you still waiting on changes?

Comment thread guides/user-experience/state-aware-sticky-headers/guide.md Outdated
}
```

Apply it to whichever element is the scroll container (typically `:root` for the document scroller). With this in place, you can freely change any property in the stuck state, including box-model properties and transforms.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can apply on the parent of the position: sticky element and it seems to work equally well. Did that work less well in your testing? If not, I think the narrower, the better, we don't need to disable scroll anchoring everywhere in the document (it's usually a good thing!) because one table somewhere has a sticky header.

Apply it to whichever element is the scroll container (typically `:root` for the document scroller). With this in place, you can freely change any property in the stuck state, including box-model properties and transforms.

### Fallback strategies
{{ BASELINE_STATUS("container-scroll-state-queries") }}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does BASELINE_STATUS work when different bcd keys have different support? Is it worth specifying a bcd key?

{{ BASELINE_STATUS("container-scroll-state-queries") }}

Scroll state queries are a progressive enhancement. In browsers that do not support `container-type: scroll-state`, the header will still stick to the top (due to `position: sticky`), but it will not visually transform. For most use cases, this is the recommended approach.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was expecting something like this to come after:

Suggested change
If stickiness is not essential, but `position: sticky` without different styling would be a worse experience than no stickiness at all, you can wrap the `position: sticky` and related declaration in an `@supports` query as well:
```css
.sticky-container {
@supports (container-type: scroll-state) {
position: sticky;
top: 0;
container-type: scroll-state;
container-name: section-header;
z-index: 10;
}
}
```


Scroll state queries are a progressive enhancement. In browsers that do not support `container-type: scroll-state`, the header will still stick to the top (due to `position: sticky`), but it will not visually transform. For most use cases, this is the recommended approach.

**Tip:** If your "stuck" styling requires a different background color for readability, consider setting your layout up so that the default styling works everywhere (Progressive Enhancement). If you must use a fallback, you can gate the `position: sticky` behaviour itself inside an `@supports (container-type: scroll-state)` query.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, just noticed it's mentioned here, I missed it in my first skim, apologies. Leaving the suggestion above in case you prefer that wording, but feel free to resolve if not!

Copy link
Copy Markdown
Collaborator

@LeaVerou LeaVerou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only thing I'd consider blocking is the point about overflow-anchor — I'd really rather avoid applying it to root unless we really need to. In my testing it seems fine applied to the sticky parent: https://codepen.io/leaverou/pen/JobKgJQ
@patrickkettner curious if you came across cases that need it on the root?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create guide and evals for the context-sensitive-sticky-headers use case

3 participants