Skip to content

Add guide and evals for state-aware sticky headers - #603

Merged
rviscomi merged 15 commits into
mainfrom
sticky-headers-guide
Aug 5, 2026
Merged

Add guide and evals for state-aware sticky headers#603
rviscomi merged 15 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

rviscomi commented May 1, 2026

Copy link
Copy Markdown
Member

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

@rviscomi
rviscomi requested a review from LeaVerou May 1, 2026 17:02
@LeaVerou

LeaVerou commented May 1, 2026

Copy link
Copy Markdown
Collaborator

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

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

@LeaVerou

LeaVerou commented May 4, 2026

Copy link
Copy Markdown
Collaborator

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.

@LeaVerou LeaVerou left a comment

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.

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/ui-atoms/state-aware-sticky-headers/guide.md
Comment thread guides/user-experience/state-aware-sticky-headers/guide.md Outdated
Comment thread guides/user-experience/state-aware-sticky-headers/guide.md Outdated
Comment thread guides/user-experience/state-aware-sticky-headers/guide.md Outdated

@LeaVerou LeaVerou left a comment

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.

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.

Comment thread guides/ui-atoms/state-aware-sticky-headers/guide.md
Comment thread guides/ui-atoms/state-aware-sticky-headers/guide.md
Comment thread guides/ui-atoms/state-aware-sticky-headers/guide.md

@LeaVerou LeaVerou left a comment

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.

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?

@rviscomi

Copy link
Copy Markdown
Member

@patrickkettner could you take a look at @LeaVerou's feedback?

@patrickkettner patrickkettner changed the title Add guide and evals for context-sensitive sticky headers Add guide and evals for state-aware sticky headers Jun 24, 2026
@patrickkettner

Copy link
Copy Markdown
Contributor Author

@rviscomi the latest push here is guide-only. I narrowed overflow-anchor: none to the sticky element's parent instead of :root and added a caveat about bottom-stuck elements, per Lea's feedback. None of it touches demo.html or the grader, so the existing calibration is unaffected, right?

I'll re-run gd eval and post the updated guided/unguided numbers here before flipping this to ready for review. Still happy to pull the eval artifacts into a separate follow-up if you'd prefer to keep this PR focused on the guidance, lemme know.

patrickkettner and others added 2 commits June 24, 2026 16:01
# Conflicts:
#	guides/scroll/context-sensitive-sticky-headers/demo.html
#	guides/scroll/context-sensitive-sticky-headers/guide.md
#	guides/user-experience/context-sensitive-sticky-headers/demo.html
#	guides/user-experience/state-aware-sticky-headers/demo.html
@patrickkettner

Copy link
Copy Markdown
Contributor Author

re-ran the evals on the current branch. 3 runs, gemini_cli (gemini-3.1-pro-preview) with skills_cli serving. all six agent executions completed, no early failures:

  • guided: 100% (21/21 checks), guide retrieved and tool activated in every run
  • unguided: 81% (17/21). two runs passed everything; the third made the headers sticky but added no stuck-state styling at all (no container-type: scroll-state, no query, no fallback), which accounts for all four failed checks

so current models will reach for scroll-state() unprompted most of the time; with the guide it was all three runs.

Let me know if youd rather this be guidance only. Not sure why I was getting such different numbers from you before, but would love to compare evals.json if you arent seeing the same results.

@rviscomi otherwise this is ready for another look. @LeaVerou the overflow-anchor scoping you flagged is in (sticky parent, not root, plus the bottom-stuck caveat).

@patrickkettner
patrickkettner requested a review from LeaVerou July 16, 2026 23:18
Comment thread guides/scroll/state-aware-sticky-headers/expectations.md Outdated
Comment thread guides/ui-atoms/state-aware-sticky-headers/expectations.md
Comment thread guides/ui-atoms/state-aware-sticky-headers/expectations.md

@LeaVerou LeaVerou left a comment

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 guide looks great! I added some suggestions/comments to the expectations, but I don't think they're blocking.

Also this may need rebasing, I see some unrelated changes that have crept in.

took her expectation rewording, dropped the IntersectionObserver
bullets that contradicted the guide's progressive enhancement stance,
and made the first task reduce padding when stuck so the
overflow-anchor guidance actually gets exercised. the grader now
checks overflow-anchor on the sticky parent both ways: required when
stuck styles change layout, absent when they dont. also reverted
unrelated dev-guide.ts changes that snuck in with an earlier merge.
typescript strictness fixes, height and line-height added to the
layout prop list (the guide's own examples lead with height), a
comment owning how conservative the list is, and scroll to the middle
of the container instead of past it. recalibrated and re-graded the
saved eval outputs, numbers unchanged.
@patrickkettner

Copy link
Copy Markdown
Contributor Author

Thanks Lea! Updated, re-ran the evals with the layout-changing task, guided 24/24, unguided 21/24 with all three misses being the missing overflow-anchor.

@rviscomi

rviscomi commented Jul 22, 2026

Copy link
Copy Markdown
Member

We shouldn't need to update the README in this PR. Removing that should resolve the merge conflict, then we can push this change through.

Edit: sorry I didn't realize the build script changed to auto-generate the README

@rviscomi
rviscomi enabled auto-merge (squash) August 5, 2026 14:07
@rviscomi
rviscomi merged commit b84068b into main Aug 5, 2026
12 checks passed
@rviscomi
rviscomi deleted the sticky-headers-guide branch August 5, 2026 14:10
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