From 388701f09cac3a128b4f3ffb3ea99301c0807622 Mon Sep 17 00:00:00 2001 From: Patrick Kettner Date: Sat, 18 Apr 2026 14:19:10 -0400 Subject: [PATCH 01/11] Add guide and evals for context-sensitive sticky headers with hardened grader --- .../demo.html | 20 +- .../expectations.md | 5 + .../grader.ts | 310 ++++++++++++++++++ .../context-sensitive-sticky-headers/guide.md | 104 ++++++ .../negative-demo.html | 72 ++++ .../tasks/task.md | 10 + 6 files changed, 508 insertions(+), 13 deletions(-) create mode 100644 guides/user-experience/context-sensitive-sticky-headers/grader.ts create mode 100644 guides/user-experience/context-sensitive-sticky-headers/negative-demo.html create mode 100644 guides/user-experience/context-sensitive-sticky-headers/tasks/task.md diff --git a/guides/user-experience/context-sensitive-sticky-headers/demo.html b/guides/user-experience/context-sensitive-sticky-headers/demo.html index d5a4acb8b..095cfa146 100644 --- a/guides/user-experience/context-sensitive-sticky-headers/demo.html +++ b/guides/user-experience/context-sensitive-sticky-headers/demo.html @@ -13,15 +13,6 @@ color: #333; } - .scroller { - height: 400px; - overflow-y: auto; - border: 1px solid #ccc; - margin: 20px; - background: #fff; - border-radius: 8px; - } - .section { padding-bottom: 50px; } @@ -31,6 +22,7 @@ position: sticky; top: 0; container-type: scroll-state; + container-name: section-header; z-index: 10; } @@ -44,7 +36,7 @@ } /* Target the inner header when the container is stuck */ - @container scroll-state(stuck: top) { + @container section-header scroll-state(stuck: top) { .sticky-header { background: #0056b3; color: white; @@ -61,8 +53,11 @@ -
-
+ + +
-
diff --git a/guides/user-experience/context-sensitive-sticky-headers/expectations.md b/guides/user-experience/context-sensitive-sticky-headers/expectations.md index e69de29bb..004633b98 100644 --- a/guides/user-experience/context-sensitive-sticky-headers/expectations.md +++ b/guides/user-experience/context-sensitive-sticky-headers/expectations.md @@ -0,0 +1,5 @@ +- The header container uses `position: sticky` to remain at the top of the scroller. +- The header container defines `container-type: scroll-state` to enable scroll state queries. +- The header container defines `container-name: section-header` to avoid collisions. +- The header visual style changes (both background color and padding) when it is stuck at the top. +- The visual style changes are implemented using the `@container scroll-state(stuck: top)` query. diff --git a/guides/user-experience/context-sensitive-sticky-headers/grader.ts b/guides/user-experience/context-sensitive-sticky-headers/grader.ts new file mode 100644 index 000000000..a484457cd --- /dev/null +++ b/guides/user-experience/context-sensitive-sticky-headers/grader.ts @@ -0,0 +1,310 @@ +import { test, expect } from '../../test-fixture.ts'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Setup +const targetFile = process.env.TARGET_FILE; +if (!targetFile) { + throw new Error('TARGET_FILE environment variable not set.'); +} + +const filePath = path.resolve(targetFile); +const targetDir = path.dirname(filePath); +const demoName = path.basename(filePath); + +test.describe(`Context-Sensitive Sticky Headers Expectations: ${demoName}`, () => { + + test.use({ + launchOptions: { + args: ['--enable-experimental-web-platform-features'], + }, + }); + + test.beforeEach(async ({ page, TARGET_URL }) => { + if (TARGET_URL.startsWith('http://localhost/')) { + await page.route('http://localhost/**', async (route) => { + const requestPath = new URL(route.request().url()).pathname; + const localFilePath = path.join(targetDir, requestPath === '/' ? demoName : requestPath); + + if (fs.existsSync(localFilePath)) { + await route.fulfill({ path: localFilePath }); + } else { + await route.continue(); + } + }); + } + await page.goto(TARGET_URL); + }); + + test('Header container should use position: sticky', async ({ page }) => { + const elements = page.locator('.sticky-container'); + const count = await elements.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) { + const position = await elements.nth(i).evaluate(el => getComputedStyle(el).position); + expect(position).toBe('sticky'); + } + }); + + test('Header container should define container-type: scroll-state', async ({ page }) => { + const elements = page.locator('.sticky-container'); + const count = await elements.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) { + const containerType = await elements.nth(i).evaluate(el => { + // @ts-ignore + return getComputedStyle(el).containerType || getComputedStyle(el).getPropertyValue('container-type'); + }); + expect(containerType).toContain('scroll-state'); + } + }); + + test('Header container should define container-name: section-header', async ({ page }) => { + const elements = page.locator('.sticky-container'); + const count = await elements.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) { + const containerName = await elements.nth(i).evaluate(el => { + // @ts-ignore + return getComputedStyle(el).containerName || getComputedStyle(el).getPropertyValue('container-name'); + }); + expect(containerName).toBe('section-header'); + } + }); + + test('Header visual style should change when stuck at the top', async ({ page }) => { + const header = page.locator('.sticky-header').first(); + + // Ensure we start at the top (unstuck) + await page.evaluate(() => window.scrollTo(0, 0)); + + // Wait for header to be at its natural position (top > 0) + await page.waitForFunction(() => { + const el = document.querySelector('.sticky-header'); + if (!el) return false; + return el.getBoundingClientRect().top > 0; + }, { timeout: 5000 }); + + const headerRect = await header.evaluate(el => { + const r = el.getBoundingClientRect(); + return { top: r.top + window.scrollY, height: r.height }; + }); + + const initialStyles = await header.evaluate(el => { + const style = getComputedStyle(el); + return { + backgroundColor: style.backgroundColor, + paddingTop: parseFloat(style.paddingTop), + paddingBottom: parseFloat(style.paddingBottom), + }; + }); + + // Assert header is actually unstuck + expect(headerRect.top).toBeGreaterThan(0); + + // Scroll to make it stick (past its natural position) + await page.evaluate((args) => { + window.scrollTo(0, args.top + args.height + 50); + }, headerRect); + + // Wait for style change (polling) + await page.waitForFunction((args) => { + const el = document.querySelector(args.selector); + if (!el) return false; + const style = getComputedStyle(el); + const pt = parseFloat(style.paddingTop); + const pb = parseFloat(style.paddingBottom); + + // Check for blue-ish color (high blue channel) + const rgb = style.backgroundColor.match(/\d+/g); + const isBlue = rgb ? (Number(rgb[2]) > Number(rgb[0]) && Number(rgb[2]) > Number(rgb[1]) && Number(rgb[2]) > 100) : false; + + // Check that background changed AND padding decreased AND it is blue + return style.backgroundColor !== args.initialBg && + (pt < args.initialPt || pb < args.initialPb) && + isBlue; + }, { + selector: '.sticky-header', + initialBg: initialStyles.backgroundColor, + initialPt: initialStyles.paddingTop, + initialPb: initialStyles.paddingBottom + }, { timeout: 5000 }); + + const stuckStyles = await header.evaluate(el => { + const style = getComputedStyle(el); + return { + backgroundColor: style.backgroundColor, + paddingTop: parseFloat(style.paddingTop), + paddingBottom: parseFloat(style.paddingBottom), + }; + }); + + // Assert specific changes + expect(stuckStyles.paddingTop).toBeLessThan(initialStyles.paddingTop); + expect(stuckStyles.backgroundColor).not.toBe(initialStyles.backgroundColor); + + const rgb = stuckStyles.backgroundColor.match(/\d+/g); + const isBlue = rgb ? (Number(rgb[2]) > Number(rgb[0]) && Number(rgb[2]) > Number(rgb[1]) && Number(rgb[2]) > 100) : false; + expect(isBlue).toBe(true); + + // Scroll back to 0 and assert revert + await page.evaluate(() => window.scrollTo(0, 0)); + + // Wait for style to revert + await page.waitForFunction((args) => { + const el = document.querySelector(args.selector); + if (!el) return false; + const style = getComputedStyle(el); + const pt = parseFloat(style.paddingTop); + const pb = parseFloat(style.paddingBottom); + return style.backgroundColor === args.initialBg && + pt === args.initialPt && + pb === args.initialPb; + }, { + selector: '.sticky-header', + initialBg: initialStyles.backgroundColor, + initialPt: initialStyles.paddingTop, + initialPb: initialStyles.paddingBottom + }, { timeout: 5000 }); + }); + + test('Visual style changes should be implemented using @container scroll-state(...)', async ({ page }) => { + const hasScrollStateQuery = await page.evaluate(() => { + const sheets = Array.from(document.styleSheets); + return sheets.some(sheet => { + try { + const rules = Array.from(sheet.cssRules); + return rules.some(rule => { + // @ts-ignore - conditionText might not be on all rules + const condition = rule.conditionText || ''; + const normalized = condition.replace(/\s+/g, ''); + const hasScrollState = normalized.includes('scroll-state'); + const hasValidStuck = normalized.includes('stuck:top') || + normalized.includes('stuck:inset-block-start') || + normalized.includes('stuck:inset-inline-start'); + + if (hasScrollState && hasValidStuck) { + return true; + } + + // Some browsers might not put it in conditionText but we can check the constructor name or other props + if (rule.constructor.name === 'CSSContainerRule') { + const cssText = rule.cssText.replace(/\s+/g, ''); + const hasCssScrollState = cssText.includes('scroll-state'); + const hasCssValidStuck = cssText.includes('stuck:top') || + cssText.includes('stuck:inset-block-start') || + cssText.includes('stuck:inset-inline-start'); + return hasCssScrollState && hasCssValidStuck; + } + return false; + }); + } catch (e) { + return false; + } + }); + }); + expect(hasScrollStateQuery).toBe(true); + }); + + test('Stuck styles must come from the container query, not JS', async ({ page }) => { + const header = page.locator('.sticky-header').first(); + + const headerRect = await header.evaluate(el => { + const r = el.getBoundingClientRect(); + return { top: r.top + window.scrollY, height: r.height }; + }); + + const initialStyles = await header.evaluate(el => { + const style = getComputedStyle(el); + return { + backgroundColor: style.backgroundColor, + paddingTop: parseFloat(style.paddingTop), + }; + }); + + // Scroll to stick (past its natural position) + await page.evaluate((args) => { + window.scrollTo(0, args.top + args.height + 50); + }, headerRect); + + // Wait for style change + await page.waitForFunction((args) => { + const el = document.querySelector(args.selector); + if (!el) return false; + const style = getComputedStyle(el); + return style.backgroundColor !== args.initialBg; + }, { selector: '.sticky-header', initialBg: initialStyles.backgroundColor }, { timeout: 5000 }); + + // Delete the container query rule (recursively) + await page.evaluate(() => { + function deleteRuleRecursive(ruleList: any, sheetOrParent: any) { + for (let i = ruleList.length - 1; i >= 0; i--) { + const r = ruleList[i]; + // @ts-ignore + const condition = r.conditionText || ''; + const normalized = condition.replace(/\s+/g, ''); + + const hasValidStuck = normalized.includes('stuck:top') || + normalized.includes('stuck:inset-block-start') || + normalized.includes('stuck:inset-inline-start'); + + if (normalized.includes('scroll-state') && hasValidStuck) { + // Delete the rule from its parent list + // @ts-ignore + sheetOrParent.deleteRule(i); + } else if (r.cssRules) { + deleteRuleRecursive(r.cssRules, r); + } + } + } + + for (const sheet of Array.from(document.styleSheets)) { + try { + deleteRuleRecursive(sheet.cssRules, sheet); + } catch {} + } + }); + + // Assert styles revert to initial + await page.waitForFunction((args) => { + const el = document.querySelector(args.selector); + if (!el) return false; + const style = getComputedStyle(el); + const pt = parseFloat(style.paddingTop); + return style.backgroundColor === args.initialBg && pt === args.initialPt; + }, { + selector: '.sticky-header', + initialBg: initialStyles.backgroundColor, + initialPt: initialStyles.paddingTop + }, { timeout: 5000 }); + + // Second arm: scroll away and back and verify it DOES NOT re-acquire stuck styles + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(300); // let any scroll handler run + + await page.evaluate((args) => { + window.scrollTo(0, args.top + args.height + 50); + }, headerRect); + await page.waitForTimeout(300); + + const stillHasStuckLook = await header.evaluate((el, args) => { + const style = getComputedStyle(el); + return style.backgroundColor !== args.initialBg; + }, { initialBg: initialStyles.backgroundColor }); + + expect(stillHasStuckLook).toBe(false); + }); test('Existing navigation bar should remain intact', async ({ page }) => { + const nav = page.locator('nav').first(); + await expect(nav).toBeVisible(); + + const logo = page.locator('nav .logo'); + await expect(logo).toBeVisible(); + + const containerType = await nav.evaluate(el => { + // @ts-ignore + return getComputedStyle(el).containerType || getComputedStyle(el).getPropertyValue('container-type'); + }); + expect(containerType).not.toContain('scroll-state'); + }); + +}); diff --git a/guides/user-experience/context-sensitive-sticky-headers/guide.md b/guides/user-experience/context-sensitive-sticky-headers/guide.md index 0a3fa09e8..e70cfabd9 100644 --- a/guides/user-experience/context-sensitive-sticky-headers/guide.md +++ b/guides/user-experience/context-sensitive-sticky-headers/guide.md @@ -3,4 +3,108 @@ name: context-sensitive-sticky-headers 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. web-feature-ids: - container-scroll-state-queries +sources: + - https://webstatus.dev/features/container-scroll-state-queries --- + +# Context-Sensitive Sticky Headers + +Sticky headers are a common UI pattern, but they often need to change their appearance when they become "stuck" to maintain readability or save space. Traditional solutions required JavaScript scroll listeners, which can cause performance issues. Modern CSS allows you to handle this natively using **Scroll State Queries**. + +## Implementation Steps + +### 1. Create the Sticky Container +You need a container that will act as the sticky element and the container for the scroll state query. + +```html + +
+
+ +
+
+ +
+
+``` + +### 2. Apply CSS for Sticky Behavior and Container Type +Define the container as `position: sticky` and set `container-type: scroll-state`. You can also combine it with size queries, e.g., `container-type: scroll-state inline-size`. + +```css +.sticky-container { + position: sticky; + top: 0; + /* Strongly recommended to avoid collisions with other scroll-state containers */ + container-type: scroll-state; + container-name: section-header; + z-index: 10; +} + +.sticky-header { + /* Base styles for the header */ + background: #f0f0f0; + padding: 20px; + transition: background-color 0.3s, box-shadow 0.3s; +} +``` + +> [!IMPORTANT] +> The scroll state is queried by **descendants** of the scroll-state container. The styles in the `@container` query will not apply to the `.sticky-container` itself, but to its children (like `.sticky-header`). You cannot style the container element itself with its own scroll-state query. + + +### 3. Target the Stuck State +Use the `@container scroll-state(...)` query to apply styles when the header is stuck. + +```css +/* Apply styles when the named container is stuck at the top */ +@container section-header scroll-state(stuck: top) { + .sticky-header { + background: #0056b3; + color: white; + box-shadow: 0 4px 6px rgba(0,0,0,0.15); + padding: 10px 20px; /* Switch to a more compact layout */ + } +} +``` + +> [!TIP] +> You can also use logical properties like `stuck: inset-block-start` or `stuck: inset-inline-start` to better support internationalization (i18n) and right-to-left (RTL) layouts by querying flow-relative edges rather than physical ones. + +### Notes on Dimension Changes +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. + +### Fallback strategies +{{ 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. + +If the visual transformation is critical for the design or accessibility, you can use a JavaScript fallback with `IntersectionObserver` to detect when the element sticks and toggle a class. + +```javascript +// Feature detection +if (!CSS.supports('container-type', 'scroll-state')) { + const observer = new IntersectionObserver( + ([entry]) => { + // Toggle class when element moves above the top edge + entry.target.classList.toggle('is-stuck', entry.intersectionRatio < 1); + }, + { + threshold: [1], + // The -1px top margin shrinks the root's intersection rect by 1px at the top. + // This forces the observer to fire when the sticky element (at top: 0) + // crosses that line, dropping the intersectionRatio below 1. + // Note: This is an approximation and assumes top: 0 and viewport scrolling. + rootMargin: '-1px 0px 0px 0px', + // root: document.querySelector('.scroller') // MANDATORY if scrolling inside an element instead of the viewport + } + ); + + const element = document.querySelector('.sticky-container'); + if (element) { + observer.observe(element); + } +} +``` diff --git a/guides/user-experience/context-sensitive-sticky-headers/negative-demo.html b/guides/user-experience/context-sensitive-sticky-headers/negative-demo.html new file mode 100644 index 000000000..9f6df26b3 --- /dev/null +++ b/guides/user-experience/context-sensitive-sticky-headers/negative-demo.html @@ -0,0 +1,72 @@ + + + + + + Static Headers + + + + +
+
+
+
+ Item 1 +
+
+
+

This text stays here.

+

No movement occurs.

+
+
+ +
+
+
+ Item 2 +
+
+
+

This is just a list.

+

Nothing sticks or changes.

+
+
+
+ + + diff --git a/guides/user-experience/context-sensitive-sticky-headers/tasks/task.md b/guides/user-experience/context-sensitive-sticky-headers/tasks/task.md new file mode 100644 index 000000000..49bccecf2 --- /dev/null +++ b/guides/user-experience/context-sensitive-sticky-headers/tasks/task.md @@ -0,0 +1,10 @@ +--- +base_app: daily-grind +--- +- 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. +- Use the class `sticky-container` for the container and `sticky-header` for the header. +- To avoid collisions with the existing site navigation, you MUST use a named container (e.g., `container-name: section-header`). +- When the headers are stuck at the top, change their background color to blue and reduce their padding. +- I want my headers to feel more compact and distinct when they are stuck at the top of the page. +- Ensure there is enough scrollable content *above* the first sticky header so that it is not stuck on initial page load. +- The implementation should assume the document/viewport is the scroll root. From 284c80d807d706c2dfed530d8ebd208a2f8d020d Mon Sep 17 00:00:00 2001 From: Rick Viscomi Date: Wed, 22 Apr 2026 15:43:26 -0400 Subject: [PATCH 02/11] rm eval files --- .../grader.ts | 310 ------------------ .../negative-demo.html | 72 ---- .../tasks/task.md | 10 - 3 files changed, 392 deletions(-) delete mode 100644 guides/user-experience/context-sensitive-sticky-headers/grader.ts delete mode 100644 guides/user-experience/context-sensitive-sticky-headers/negative-demo.html delete mode 100644 guides/user-experience/context-sensitive-sticky-headers/tasks/task.md diff --git a/guides/user-experience/context-sensitive-sticky-headers/grader.ts b/guides/user-experience/context-sensitive-sticky-headers/grader.ts deleted file mode 100644 index a484457cd..000000000 --- a/guides/user-experience/context-sensitive-sticky-headers/grader.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { test, expect } from '../../test-fixture.ts'; -import * as fs from 'fs'; -import * as path from 'path'; - -// Setup -const targetFile = process.env.TARGET_FILE; -if (!targetFile) { - throw new Error('TARGET_FILE environment variable not set.'); -} - -const filePath = path.resolve(targetFile); -const targetDir = path.dirname(filePath); -const demoName = path.basename(filePath); - -test.describe(`Context-Sensitive Sticky Headers Expectations: ${demoName}`, () => { - - test.use({ - launchOptions: { - args: ['--enable-experimental-web-platform-features'], - }, - }); - - test.beforeEach(async ({ page, TARGET_URL }) => { - if (TARGET_URL.startsWith('http://localhost/')) { - await page.route('http://localhost/**', async (route) => { - const requestPath = new URL(route.request().url()).pathname; - const localFilePath = path.join(targetDir, requestPath === '/' ? demoName : requestPath); - - if (fs.existsSync(localFilePath)) { - await route.fulfill({ path: localFilePath }); - } else { - await route.continue(); - } - }); - } - await page.goto(TARGET_URL); - }); - - test('Header container should use position: sticky', async ({ page }) => { - const elements = page.locator('.sticky-container'); - const count = await elements.count(); - expect(count).toBeGreaterThan(0); - for (let i = 0; i < count; i++) { - const position = await elements.nth(i).evaluate(el => getComputedStyle(el).position); - expect(position).toBe('sticky'); - } - }); - - test('Header container should define container-type: scroll-state', async ({ page }) => { - const elements = page.locator('.sticky-container'); - const count = await elements.count(); - expect(count).toBeGreaterThan(0); - for (let i = 0; i < count; i++) { - const containerType = await elements.nth(i).evaluate(el => { - // @ts-ignore - return getComputedStyle(el).containerType || getComputedStyle(el).getPropertyValue('container-type'); - }); - expect(containerType).toContain('scroll-state'); - } - }); - - test('Header container should define container-name: section-header', async ({ page }) => { - const elements = page.locator('.sticky-container'); - const count = await elements.count(); - expect(count).toBeGreaterThan(0); - for (let i = 0; i < count; i++) { - const containerName = await elements.nth(i).evaluate(el => { - // @ts-ignore - return getComputedStyle(el).containerName || getComputedStyle(el).getPropertyValue('container-name'); - }); - expect(containerName).toBe('section-header'); - } - }); - - test('Header visual style should change when stuck at the top', async ({ page }) => { - const header = page.locator('.sticky-header').first(); - - // Ensure we start at the top (unstuck) - await page.evaluate(() => window.scrollTo(0, 0)); - - // Wait for header to be at its natural position (top > 0) - await page.waitForFunction(() => { - const el = document.querySelector('.sticky-header'); - if (!el) return false; - return el.getBoundingClientRect().top > 0; - }, { timeout: 5000 }); - - const headerRect = await header.evaluate(el => { - const r = el.getBoundingClientRect(); - return { top: r.top + window.scrollY, height: r.height }; - }); - - const initialStyles = await header.evaluate(el => { - const style = getComputedStyle(el); - return { - backgroundColor: style.backgroundColor, - paddingTop: parseFloat(style.paddingTop), - paddingBottom: parseFloat(style.paddingBottom), - }; - }); - - // Assert header is actually unstuck - expect(headerRect.top).toBeGreaterThan(0); - - // Scroll to make it stick (past its natural position) - await page.evaluate((args) => { - window.scrollTo(0, args.top + args.height + 50); - }, headerRect); - - // Wait for style change (polling) - await page.waitForFunction((args) => { - const el = document.querySelector(args.selector); - if (!el) return false; - const style = getComputedStyle(el); - const pt = parseFloat(style.paddingTop); - const pb = parseFloat(style.paddingBottom); - - // Check for blue-ish color (high blue channel) - const rgb = style.backgroundColor.match(/\d+/g); - const isBlue = rgb ? (Number(rgb[2]) > Number(rgb[0]) && Number(rgb[2]) > Number(rgb[1]) && Number(rgb[2]) > 100) : false; - - // Check that background changed AND padding decreased AND it is blue - return style.backgroundColor !== args.initialBg && - (pt < args.initialPt || pb < args.initialPb) && - isBlue; - }, { - selector: '.sticky-header', - initialBg: initialStyles.backgroundColor, - initialPt: initialStyles.paddingTop, - initialPb: initialStyles.paddingBottom - }, { timeout: 5000 }); - - const stuckStyles = await header.evaluate(el => { - const style = getComputedStyle(el); - return { - backgroundColor: style.backgroundColor, - paddingTop: parseFloat(style.paddingTop), - paddingBottom: parseFloat(style.paddingBottom), - }; - }); - - // Assert specific changes - expect(stuckStyles.paddingTop).toBeLessThan(initialStyles.paddingTop); - expect(stuckStyles.backgroundColor).not.toBe(initialStyles.backgroundColor); - - const rgb = stuckStyles.backgroundColor.match(/\d+/g); - const isBlue = rgb ? (Number(rgb[2]) > Number(rgb[0]) && Number(rgb[2]) > Number(rgb[1]) && Number(rgb[2]) > 100) : false; - expect(isBlue).toBe(true); - - // Scroll back to 0 and assert revert - await page.evaluate(() => window.scrollTo(0, 0)); - - // Wait for style to revert - await page.waitForFunction((args) => { - const el = document.querySelector(args.selector); - if (!el) return false; - const style = getComputedStyle(el); - const pt = parseFloat(style.paddingTop); - const pb = parseFloat(style.paddingBottom); - return style.backgroundColor === args.initialBg && - pt === args.initialPt && - pb === args.initialPb; - }, { - selector: '.sticky-header', - initialBg: initialStyles.backgroundColor, - initialPt: initialStyles.paddingTop, - initialPb: initialStyles.paddingBottom - }, { timeout: 5000 }); - }); - - test('Visual style changes should be implemented using @container scroll-state(...)', async ({ page }) => { - const hasScrollStateQuery = await page.evaluate(() => { - const sheets = Array.from(document.styleSheets); - return sheets.some(sheet => { - try { - const rules = Array.from(sheet.cssRules); - return rules.some(rule => { - // @ts-ignore - conditionText might not be on all rules - const condition = rule.conditionText || ''; - const normalized = condition.replace(/\s+/g, ''); - const hasScrollState = normalized.includes('scroll-state'); - const hasValidStuck = normalized.includes('stuck:top') || - normalized.includes('stuck:inset-block-start') || - normalized.includes('stuck:inset-inline-start'); - - if (hasScrollState && hasValidStuck) { - return true; - } - - // Some browsers might not put it in conditionText but we can check the constructor name or other props - if (rule.constructor.name === 'CSSContainerRule') { - const cssText = rule.cssText.replace(/\s+/g, ''); - const hasCssScrollState = cssText.includes('scroll-state'); - const hasCssValidStuck = cssText.includes('stuck:top') || - cssText.includes('stuck:inset-block-start') || - cssText.includes('stuck:inset-inline-start'); - return hasCssScrollState && hasCssValidStuck; - } - return false; - }); - } catch (e) { - return false; - } - }); - }); - expect(hasScrollStateQuery).toBe(true); - }); - - test('Stuck styles must come from the container query, not JS', async ({ page }) => { - const header = page.locator('.sticky-header').first(); - - const headerRect = await header.evaluate(el => { - const r = el.getBoundingClientRect(); - return { top: r.top + window.scrollY, height: r.height }; - }); - - const initialStyles = await header.evaluate(el => { - const style = getComputedStyle(el); - return { - backgroundColor: style.backgroundColor, - paddingTop: parseFloat(style.paddingTop), - }; - }); - - // Scroll to stick (past its natural position) - await page.evaluate((args) => { - window.scrollTo(0, args.top + args.height + 50); - }, headerRect); - - // Wait for style change - await page.waitForFunction((args) => { - const el = document.querySelector(args.selector); - if (!el) return false; - const style = getComputedStyle(el); - return style.backgroundColor !== args.initialBg; - }, { selector: '.sticky-header', initialBg: initialStyles.backgroundColor }, { timeout: 5000 }); - - // Delete the container query rule (recursively) - await page.evaluate(() => { - function deleteRuleRecursive(ruleList: any, sheetOrParent: any) { - for (let i = ruleList.length - 1; i >= 0; i--) { - const r = ruleList[i]; - // @ts-ignore - const condition = r.conditionText || ''; - const normalized = condition.replace(/\s+/g, ''); - - const hasValidStuck = normalized.includes('stuck:top') || - normalized.includes('stuck:inset-block-start') || - normalized.includes('stuck:inset-inline-start'); - - if (normalized.includes('scroll-state') && hasValidStuck) { - // Delete the rule from its parent list - // @ts-ignore - sheetOrParent.deleteRule(i); - } else if (r.cssRules) { - deleteRuleRecursive(r.cssRules, r); - } - } - } - - for (const sheet of Array.from(document.styleSheets)) { - try { - deleteRuleRecursive(sheet.cssRules, sheet); - } catch {} - } - }); - - // Assert styles revert to initial - await page.waitForFunction((args) => { - const el = document.querySelector(args.selector); - if (!el) return false; - const style = getComputedStyle(el); - const pt = parseFloat(style.paddingTop); - return style.backgroundColor === args.initialBg && pt === args.initialPt; - }, { - selector: '.sticky-header', - initialBg: initialStyles.backgroundColor, - initialPt: initialStyles.paddingTop - }, { timeout: 5000 }); - - // Second arm: scroll away and back and verify it DOES NOT re-acquire stuck styles - await page.evaluate(() => window.scrollTo(0, 0)); - await page.waitForTimeout(300); // let any scroll handler run - - await page.evaluate((args) => { - window.scrollTo(0, args.top + args.height + 50); - }, headerRect); - await page.waitForTimeout(300); - - const stillHasStuckLook = await header.evaluate((el, args) => { - const style = getComputedStyle(el); - return style.backgroundColor !== args.initialBg; - }, { initialBg: initialStyles.backgroundColor }); - - expect(stillHasStuckLook).toBe(false); - }); test('Existing navigation bar should remain intact', async ({ page }) => { - const nav = page.locator('nav').first(); - await expect(nav).toBeVisible(); - - const logo = page.locator('nav .logo'); - await expect(logo).toBeVisible(); - - const containerType = await nav.evaluate(el => { - // @ts-ignore - return getComputedStyle(el).containerType || getComputedStyle(el).getPropertyValue('container-type'); - }); - expect(containerType).not.toContain('scroll-state'); - }); - -}); diff --git a/guides/user-experience/context-sensitive-sticky-headers/negative-demo.html b/guides/user-experience/context-sensitive-sticky-headers/negative-demo.html deleted file mode 100644 index 9f6df26b3..000000000 --- a/guides/user-experience/context-sensitive-sticky-headers/negative-demo.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - Static Headers - - - - -
-
-
-
- Item 1 -
-
-
-

This text stays here.

-

No movement occurs.

-
-
- -
-
-
- Item 2 -
-
-
-

This is just a list.

-

Nothing sticks or changes.

-
-
-
- - - diff --git a/guides/user-experience/context-sensitive-sticky-headers/tasks/task.md b/guides/user-experience/context-sensitive-sticky-headers/tasks/task.md deleted file mode 100644 index 49bccecf2..000000000 --- a/guides/user-experience/context-sensitive-sticky-headers/tasks/task.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -base_app: daily-grind ---- -- 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. -- Use the class `sticky-container` for the container and `sticky-header` for the header. -- To avoid collisions with the existing site navigation, you MUST use a named container (e.g., `container-name: section-header`). -- When the headers are stuck at the top, change their background color to blue and reduce their padding. -- I want my headers to feel more compact and distinct when they are stuck at the top of the page. -- Ensure there is enough scrollable content *above* the first sticky header so that it is not stuck on initial page load. -- The implementation should assume the document/viewport is the scroll root. From 43ccd38b601da80c4ad0883630dd24960d409d4d Mon Sep 17 00:00:00 2001 From: Patrick Kettner Date: Sat, 9 May 2026 20:50:30 -0400 Subject: [PATCH 03/11] Fix eval tests and finalize state-aware sticky headers --- guides/dev-guide.ts | 1 + .../context-sensitive-sticky-headers/guide.md | 110 ------- .../demo.html | 51 ++- .../expectations.md | 4 +- .../state-aware-sticky-headers/grader.ts | 291 ++++++++++++++++++ .../state-aware-sticky-headers/guide.md | 130 ++++++++ .../negative-demo.html | 94 ++++++ .../state-aware-sticky-headers/tasks/task.md | 5 + 8 files changed, 571 insertions(+), 115 deletions(-) delete mode 100644 guides/user-experience/context-sensitive-sticky-headers/guide.md rename guides/user-experience/{context-sensitive-sticky-headers => state-aware-sticky-headers}/demo.html (61%) rename guides/user-experience/{context-sensitive-sticky-headers => state-aware-sticky-headers}/expectations.md (54%) create mode 100644 guides/user-experience/state-aware-sticky-headers/grader.ts create mode 100644 guides/user-experience/state-aware-sticky-headers/guide.md create mode 100644 guides/user-experience/state-aware-sticky-headers/negative-demo.html create mode 100644 guides/user-experience/state-aware-sticky-headers/tasks/task.md diff --git a/guides/dev-guide.ts b/guides/dev-guide.ts index 3046fffa8..c87697439 100644 --- a/guides/dev-guide.ts +++ b/guides/dev-guide.ts @@ -354,6 +354,7 @@ async function runAgentTest(targetDir: string, guideName: string, guidedOnly = f numRuns: 1, skipEval: true, guidedOnly, + suiteConfig, }); // 3. Grade agent output (unguided + guided) diff --git a/guides/user-experience/context-sensitive-sticky-headers/guide.md b/guides/user-experience/context-sensitive-sticky-headers/guide.md deleted file mode 100644 index e70cfabd9..000000000 --- a/guides/user-experience/context-sensitive-sticky-headers/guide.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: context-sensitive-sticky-headers -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. -web-feature-ids: - - container-scroll-state-queries -sources: - - https://webstatus.dev/features/container-scroll-state-queries ---- - -# Context-Sensitive Sticky Headers - -Sticky headers are a common UI pattern, but they often need to change their appearance when they become "stuck" to maintain readability or save space. Traditional solutions required JavaScript scroll listeners, which can cause performance issues. Modern CSS allows you to handle this natively using **Scroll State Queries**. - -## Implementation Steps - -### 1. Create the Sticky Container -You need a container that will act as the sticky element and the container for the scroll state query. - -```html - -
-
- -
-
- -
-
-``` - -### 2. Apply CSS for Sticky Behavior and Container Type -Define the container as `position: sticky` and set `container-type: scroll-state`. You can also combine it with size queries, e.g., `container-type: scroll-state inline-size`. - -```css -.sticky-container { - position: sticky; - top: 0; - /* Strongly recommended to avoid collisions with other scroll-state containers */ - container-type: scroll-state; - container-name: section-header; - z-index: 10; -} - -.sticky-header { - /* Base styles for the header */ - background: #f0f0f0; - padding: 20px; - transition: background-color 0.3s, box-shadow 0.3s; -} -``` - -> [!IMPORTANT] -> The scroll state is queried by **descendants** of the scroll-state container. The styles in the `@container` query will not apply to the `.sticky-container` itself, but to its children (like `.sticky-header`). You cannot style the container element itself with its own scroll-state query. - - -### 3. Target the Stuck State -Use the `@container scroll-state(...)` query to apply styles when the header is stuck. - -```css -/* Apply styles when the named container is stuck at the top */ -@container section-header scroll-state(stuck: top) { - .sticky-header { - background: #0056b3; - color: white; - box-shadow: 0 4px 6px rgba(0,0,0,0.15); - padding: 10px 20px; /* Switch to a more compact layout */ - } -} -``` - -> [!TIP] -> You can also use logical properties like `stuck: inset-block-start` or `stuck: inset-inline-start` to better support internationalization (i18n) and right-to-left (RTL) layouts by querying flow-relative edges rather than physical ones. - -### Notes on Dimension Changes -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. - -### Fallback strategies -{{ 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. - -If the visual transformation is critical for the design or accessibility, you can use a JavaScript fallback with `IntersectionObserver` to detect when the element sticks and toggle a class. - -```javascript -// Feature detection -if (!CSS.supports('container-type', 'scroll-state')) { - const observer = new IntersectionObserver( - ([entry]) => { - // Toggle class when element moves above the top edge - entry.target.classList.toggle('is-stuck', entry.intersectionRatio < 1); - }, - { - threshold: [1], - // The -1px top margin shrinks the root's intersection rect by 1px at the top. - // This forces the observer to fire when the sticky element (at top: 0) - // crosses that line, dropping the intersectionRatio below 1. - // Note: This is an approximation and assumes top: 0 and viewport scrolling. - rootMargin: '-1px 0px 0px 0px', - // root: document.querySelector('.scroller') // MANDATORY if scrolling inside an element instead of the viewport - } - ); - - const element = document.querySelector('.sticky-container'); - if (element) { - observer.observe(element); - } -} -``` diff --git a/guides/user-experience/context-sensitive-sticky-headers/demo.html b/guides/user-experience/state-aware-sticky-headers/demo.html similarity index 61% rename from guides/user-experience/context-sensitive-sticky-headers/demo.html rename to guides/user-experience/state-aware-sticky-headers/demo.html index 095cfa146..2341c9220 100644 --- a/guides/user-experience/context-sensitive-sticky-headers/demo.html +++ b/guides/user-experience/state-aware-sticky-headers/demo.html @@ -3,7 +3,7 @@ - Context-Sensitive Sticky Headers + State-Aware Sticky Headers + + + + +
+ +
+
Content Block 1
+
Content Block 2
+
Content Block 3
+
Content Block 4
+
Content Block 5
+
+ + + + diff --git a/guides/user-experience/state-aware-sticky-headers/tasks/task.md b/guides/user-experience/state-aware-sticky-headers/tasks/task.md new file mode 100644 index 000000000..71029b1fd --- /dev/null +++ b/guides/user-experience/state-aware-sticky-headers/tasks/task.md @@ -0,0 +1,5 @@ +--- +base_app: daily-grind +--- +- 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. Use the class `sticky-container` for the container and `sticky-header` for the header. To avoid collisions with the existing site navigation, use a named container. When the headers are stuck at the top, change their background color to blue and add a shadow. Ensure there is enough scrollable content *above* the first sticky header so that it is not stuck on initial page load. The implementation should assume the document/viewport is the scroll root. +- Add sticky headers to the main sections of the page that stick to the top of the viewport as I scroll down. I want them to look compact and distinct when they are stuck, maybe a blue background with a shadow. Please use modern CSS container queries instead of Javascript so it's performant. Ensure there is enough scrollable content *above* the first sticky header so that it is not stuck on initial page load. From cc616a8410ded790cf34b7b6eded595625bb5d2b Mon Sep 17 00:00:00 2001 From: Patrick Kettner Date: Tue, 12 May 2026 15:52:17 -0500 Subject: [PATCH 04/11] updates from review --- .../state-aware-sticky-headers/guide.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/guides/user-experience/state-aware-sticky-headers/guide.md b/guides/user-experience/state-aware-sticky-headers/guide.md index 1abef3f74..e46e20d35 100644 --- a/guides/user-experience/state-aware-sticky-headers/guide.md +++ b/guides/user-experience/state-aware-sticky-headers/guide.md @@ -31,7 +31,7 @@ You need a container that will act as the sticky element and the container for t ``` ### 2. Apply CSS for Sticky Behavior and Container Type -Define the container as `position: sticky` and set `container-type: scroll-state`. You can also combine it with size queries, e.g., `container-type: scroll-state inline-size`. +Set `container-type: scroll-state` on the `position: sticky` element, not on its scrollable ancestor. Always specify a `container-name`, since these queries may be nested and unnamed containers will collide. You can also combine it with size queries, e.g., `container-type: scroll-state inline-size`. ```css .sticky-container { @@ -72,16 +72,17 @@ Use the `@container scroll-state(...)` query to apply styles when the header is ### 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). +Without scroll anchoring disabled, changing layout-affecting properties (height, padding, font-size, transforms) when stuck can cause visual flickering. Scroll anchoring on the in-flow content below the sticky element adjusts the scroll offset to compensate for the layout change, which pushes the element back out of its stuck position and triggers an oscillation. -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`. +Disable scroll anchoring on the scroll container to avoid this: -If you absolutely *must* change the physical dimensions when stuck, you can use one of these workarounds, though both have significant downsides: -1. **The `::after` counterweight**: Add a pseudo-element to the `.sticky-container` that perfectly negates the dimension loss of the `.sticky-header`. For example, if the header loses 20px of padding, the container's `::after` must gain exactly 20px of height. This is very brittle. -2. **The `min-height` lock**: Explicitly set the `.sticky-container` to have a `min-height` equal to the unstuck height of the header. +```css +:root { + overflow-anchor: none; +} +``` -**Important Note for Workarounds:** If you use these dimension-shifting workarounds, the container will leave behind empty, scrollable space when stuck. To prevent users from hovering or clicking this ghost space, you must set `pointer-events: none` on the container, and `pointer-events: auto` on the `.sticky-header` itself. +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") }} From 2355bd2bc643692e4b121f8dd4e91ef2dd57472e Mon Sep 17 00:00:00 2001 From: patrick kettner Date: Mon, 18 May 2026 20:57:57 -0400 Subject: [PATCH 05/11] Update guides/user-experience/state-aware-sticky-headers/guide.md Co-authored-by: Lea Verou --- guides/user-experience/state-aware-sticky-headers/guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/user-experience/state-aware-sticky-headers/guide.md b/guides/user-experience/state-aware-sticky-headers/guide.md index e46e20d35..584a340c1 100644 --- a/guides/user-experience/state-aware-sticky-headers/guide.md +++ b/guides/user-experience/state-aware-sticky-headers/guide.md @@ -72,7 +72,7 @@ Use the `@container scroll-state(...)` query to apply styles when the header is ### Notes on Dimension Changes -Without scroll anchoring disabled, changing layout-affecting properties (height, padding, font-size, transforms) when stuck can cause visual flickering. Scroll anchoring on the in-flow content below the sticky element adjusts the scroll offset to compensate for the layout change, which pushes the element back out of its stuck position and triggers an oscillation. +Without scroll anchoring disabled, changing layout-affecting properties (height, padding, font-size) when stuck can cause visual flickering. Scroll anchoring on the in-flow content below the sticky element adjusts the scroll offset to compensate for the layout change, which pushes the element back out of its stuck position and triggers an oscillation. Disable scroll anchoring on the scroll container to avoid this: From c86edf4a451c483ddb6fa9a824d243f9d4e79e15 Mon Sep 17 00:00:00 2001 From: Patrick Kettner Date: Wed, 24 Jun 2026 14:44:07 -0500 Subject: [PATCH 06/11] narrow overflow-anchor scope to sticky parent per review --- .../user-experience/state-aware-sticky-headers/guide.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/guides/user-experience/state-aware-sticky-headers/guide.md b/guides/user-experience/state-aware-sticky-headers/guide.md index 584a340c1..f36759e07 100644 --- a/guides/user-experience/state-aware-sticky-headers/guide.md +++ b/guides/user-experience/state-aware-sticky-headers/guide.md @@ -74,15 +74,17 @@ Use the `@container scroll-state(...)` query to apply styles when the header is Without scroll anchoring disabled, changing layout-affecting properties (height, padding, font-size) when stuck can cause visual flickering. Scroll anchoring on the in-flow content below the sticky element adjusts the scroll offset to compensate for the layout change, which pushes the element back out of its stuck position and triggers an oscillation. -Disable scroll anchoring on the scroll container to avoid this: +Disable scroll anchoring on the parent of the sticky element to avoid this: ```css -:root { +.section { overflow-anchor: none; } ``` -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. +Apply `overflow-anchor: none` to the direct parent of the `position: sticky` element — not to `:root` or `body`, which would disable scroll anchoring for the entire page. With this in place, you can freely change any property in the stuck state, including box-model properties and transforms. + +DO NOT: Rely on `overflow-anchor: none` for elements stuck to the **bottom**. Scroll anchoring only compensates for layout shifts above the current scroll position, so it has no effect on bottom-stuck elements. Avoid changing box-model properties in the stuck state for bottom-stuck headers. ### Fallback strategies {{ BASELINE_STATUS("container-scroll-state-queries") }} From 5ab783758c6cdde0b748fcdcbfea9558aaf3a70e Mon Sep 17 00:00:00 2001 From: patrick kettner Date: Wed, 24 Jun 2026 16:01:50 -0400 Subject: [PATCH 07/11] Update guides/user-experience/state-aware-sticky-headers/guide.md Co-authored-by: Lea Verou --- .../state-aware-sticky-headers/guide.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/guides/user-experience/state-aware-sticky-headers/guide.md b/guides/user-experience/state-aware-sticky-headers/guide.md index f36759e07..58d5d68cd 100644 --- a/guides/user-experience/state-aware-sticky-headers/guide.md +++ b/guides/user-experience/state-aware-sticky-headers/guide.md @@ -91,6 +91,19 @@ DO NOT: Rely on `overflow-anchor: none` for elements stuck to the **bottom**. Sc 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. +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; + } +} +``` **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. If the visual transformation is absolutely critical to the design (e.g., the stuck state introduces a background or compaction without which the content is unreadable), you can implement a robust JavaScript fallback using `IntersectionObserver`. You must duplicate your CSS styles under an `.is-stuck` class. The following generic polyfill checks `getComputedStyle` to traverse up and find the correct scroll container (defaulting to the viewport), matching the behavior of `@container scroll-state(stuck: top)`: From 20334d459a769daa766a8823d09920b7d35db15f Mon Sep 17 00:00:00 2001 From: patrick kettner Date: Fri, 17 Jul 2026 10:43:31 -0400 Subject: [PATCH 08/11] address lea's review 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. --- guides/dev-guide.ts | 13 ++-- .../expectations.md | 4 +- .../state-aware-sticky-headers/grader.ts | 59 +++++++++++++++++++ .../state-aware-sticky-headers/guide.md | 2 +- .../state-aware-sticky-headers/tasks/task.md | 2 +- 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/guides/dev-guide.ts b/guides/dev-guide.ts index c20e641ef..bfc549c18 100644 --- a/guides/dev-guide.ts +++ b/guides/dev-guide.ts @@ -349,7 +349,6 @@ async function runAgentTest(targetDir: string, guideName: string, guidedOnly = f numRuns: 1, skipEval: true, guidedOnly, - suiteConfig, }); // 3. Grade agent output (unguided + guided) @@ -507,12 +506,12 @@ export async function devAll(options: DevGuideOptions = {}): Promise { } const statusLabel: Record string }> = { - 'incomplete': { label: 'Incomplete (missing guide.md or demo.html)', color: cRed }, - 'stub': { label: 'Stub (yaml frontmatter only, no content)', color: cYellow }, - 'needs-expectations': { label: 'Needs expectations.md', color: cYellow }, - 'needs-calibration': { label: 'Needs calibration (run gd dev)', color: cYellow }, - 'needs-test': { label: 'Needs agent test run (missing prompts/task)', color: cCyan }, - 'eval-ready': { label: 'Ready for eval', color: cGreen }, + 'incomplete': { label: 'Needs use cases (Stage 1 - incomplete)', color: cRed }, + 'stub': { label: 'Needs guidance (Stage 2 - stub)', color: cYellow }, + 'needs-expectations': { label: 'Needs guidance (Stage 2 - missing expectations)', color: cYellow }, + 'needs-calibration': { label: 'Needs evals (Stage 3 - needs calibration)', color: cYellow }, + 'needs-test': { label: 'Needs evals (Stage 3 - needs agent test)', color: cCyan }, + 'eval-ready': { label: 'Eval-ready (Complete)', color: cGreen }, }; export function auditGuides(options: { groupByUsecases?: boolean } = {}): void { diff --git a/guides/scroll/state-aware-sticky-headers/expectations.md b/guides/scroll/state-aware-sticky-headers/expectations.md index 30384d212..965a8d9f4 100644 --- a/guides/scroll/state-aware-sticky-headers/expectations.md +++ b/guides/scroll/state-aware-sticky-headers/expectations.md @@ -1,7 +1,7 @@ - The header container uses `position: sticky` to remain at the top of the scroller. +- If any of the stuck styles change layout, `overflow-anchor: none` is applied to the parent of the header container. +- If none of the stuck styles change layout, `overflow-anchor: none` should NOT be applied to the parent of the header container. - The header container defines `container-type: scroll-state` to enable scroll state queries. - The header container defines `container-name: section-header` to avoid collisions. - The header visual style changes (e.g., background color or box-shadow) when it is stuck at the top. - The visual style changes are implemented using the `@container scroll-state(stuck: top)` query. -- A functional JavaScript fallback using `IntersectionObserver` is provided for browsers that do not support scroll-state queries. -- The fallback markup and script must be present in the rendered output. diff --git a/guides/scroll/state-aware-sticky-headers/grader.ts b/guides/scroll/state-aware-sticky-headers/grader.ts index e41d2d71c..e29b500d7 100644 --- a/guides/scroll/state-aware-sticky-headers/grader.ts +++ b/guides/scroll/state-aware-sticky-headers/grader.ts @@ -152,6 +152,65 @@ test.describe(`State-Aware Sticky Headers Expectations: ${demoName}`, () => { }, { timeout: 5000 }); }); + test('Layout-affecting stuck styles require overflow-anchor: none on the sticky parent', async ({ page }) => { + const container = page.locator('.sticky-container').first(); + + // Ensure we start unstuck + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForFunction(() => { + const el = document.querySelector('.sticky-container'); + if (!el) return false; + return el.getBoundingClientRect().top > 0; + }, { timeout: 5000 }); + + const LAYOUT_PROPS = [ + 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', + 'marginTop', 'marginBottom', 'fontSize', + 'borderTopWidth', 'borderBottomWidth', + ]; + + const readLayout = () => page.evaluate((props) => { + const pick = (el) => { + const style = getComputedStyle(el); + return props.map((prop) => style[prop]).join('|'); + }; + const containerEl = document.querySelector('.sticky-container'); + const headerEl = containerEl.querySelector('.sticky-header') || containerEl; + return pick(containerEl) + '||' + pick(headerEl); + }, LAYOUT_PROPS); + + const unstuck = await readLayout(); + + const containerRect = await container.evaluate(el => { + const r = el.getBoundingClientRect(); + return { top: r.top + window.scrollY, height: r.height }; + }); + + // Scroll past the container so it sticks + await page.evaluate((args) => { + window.scrollTo(0, args.top + args.height + 50); + }, containerRect); + + // Give the two-pass scroll-state update a moment to settle + await page.waitForTimeout(500); + + const stuck = await readLayout(); + const layoutChanged = stuck !== unstuck; + + const parentOverflowAnchor = await container.evaluate(el => + getComputedStyle(el.parentElement).overflowAnchor + ); + + if (layoutChanged) { + // The guide requires disabling scroll anchoring on the parent when + // stuck styles change layout, otherwise the header oscillates. + expect(parentOverflowAnchor).toBe('none'); + } else { + // No layout change means scroll anchoring must stay enabled. + expect(parentOverflowAnchor).not.toBe('none'); + } + }); + test('Visual style changes should be implemented using @container scroll-state(...)', async ({ page }) => { const hasScrollStateQuery = await page.evaluate(() => { const sheets = Array.from(document.styleSheets); diff --git a/guides/scroll/state-aware-sticky-headers/guide.md b/guides/scroll/state-aware-sticky-headers/guide.md index 58d5d68cd..0f820169d 100644 --- a/guides/scroll/state-aware-sticky-headers/guide.md +++ b/guides/scroll/state-aware-sticky-headers/guide.md @@ -82,7 +82,7 @@ Disable scroll anchoring on the parent of the sticky element to avoid this: } ``` -Apply `overflow-anchor: none` to the direct parent of the `position: sticky` element — not to `:root` or `body`, which would disable scroll anchoring for the entire page. With this in place, you can freely change any property in the stuck state, including box-model properties and transforms. +Apply `overflow-anchor: none` to the direct parent of the `position: sticky` element, not to `:root` or `body`, which would disable scroll anchoring for the entire page. With this in place, you can freely change any property in the stuck state, including box-model properties and transforms. DO NOT: Rely on `overflow-anchor: none` for elements stuck to the **bottom**. Scroll anchoring only compensates for layout shifts above the current scroll position, so it has no effect on bottom-stuck elements. Avoid changing box-model properties in the stuck state for bottom-stuck headers. diff --git a/guides/scroll/state-aware-sticky-headers/tasks/task.md b/guides/scroll/state-aware-sticky-headers/tasks/task.md index 71029b1fd..4f52d2c3e 100644 --- a/guides/scroll/state-aware-sticky-headers/tasks/task.md +++ b/guides/scroll/state-aware-sticky-headers/tasks/task.md @@ -1,5 +1,5 @@ --- base_app: daily-grind --- -- 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. Use the class `sticky-container` for the container and `sticky-header` for the header. To avoid collisions with the existing site navigation, use a named container. When the headers are stuck at the top, change their background color to blue and add a shadow. Ensure there is enough scrollable content *above* the first sticky header so that it is not stuck on initial page load. The implementation should assume the document/viewport is the scroll root. +- 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. Use the class `sticky-container` for the container and `sticky-header` for the header. To avoid collisions with the existing site navigation, use a named container. When the headers are stuck at the top, change their background color to blue, add a shadow, and reduce their padding so they take up less room. Ensure there is enough scrollable content *above* the first sticky header so that it is not stuck on initial page load. The implementation should assume the document/viewport is the scroll root. - Add sticky headers to the main sections of the page that stick to the top of the viewport as I scroll down. I want them to look compact and distinct when they are stuck, maybe a blue background with a shadow. Please use modern CSS container queries instead of Javascript so it's performant. Ensure there is enough scrollable content *above* the first sticky header so that it is not stuck on initial page load. From 0dfe1172c3193729da70f0a686ab6d43bd85fff7 Mon Sep 17 00:00:00 2001 From: patrick kettner Date: Fri, 17 Jul 2026 10:53:42 -0400 Subject: [PATCH 09/11] tighten the new grader check 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. --- .../scroll/state-aware-sticky-headers/grader.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/guides/scroll/state-aware-sticky-headers/grader.ts b/guides/scroll/state-aware-sticky-headers/grader.ts index e29b500d7..891586efd 100644 --- a/guides/scroll/state-aware-sticky-headers/grader.ts +++ b/guides/scroll/state-aware-sticky-headers/grader.ts @@ -163,18 +163,23 @@ test.describe(`State-Aware Sticky Headers Expectations: ${demoName}`, () => { return el.getBoundingClientRect().top > 0; }, { timeout: 5000 }); + // Deliberately conservative: some of these only cause anchoring + // oscillation in some layouts, but a binary check cannot cheaply + // test actual flicker, so any layout-affecting change counts. const LAYOUT_PROPS = [ + 'height', 'minHeight', 'lineHeight', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'marginTop', 'marginBottom', 'fontSize', 'borderTopWidth', 'borderBottomWidth', ]; const readLayout = () => page.evaluate((props) => { - const pick = (el) => { + const pick = (el: Element) => { const style = getComputedStyle(el); - return props.map((prop) => style[prop]).join('|'); + return props.map((prop) => String(style[prop as keyof CSSStyleDeclaration])).join('|'); }; const containerEl = document.querySelector('.sticky-container'); + if (!containerEl) return ''; const headerEl = containerEl.querySelector('.sticky-header') || containerEl; return pick(containerEl) + '||' + pick(headerEl); }, LAYOUT_PROPS); @@ -186,9 +191,9 @@ test.describe(`State-Aware Sticky Headers Expectations: ${demoName}`, () => { return { top: r.top + window.scrollY, height: r.height }; }); - // Scroll past the container so it sticks + // Scroll halfway into the container so it is unambiguously stuck await page.evaluate((args) => { - window.scrollTo(0, args.top + args.height + 50); + window.scrollTo(0, args.top + args.height / 2); }, containerRect); // Give the two-pass scroll-state update a moment to settle @@ -198,7 +203,7 @@ test.describe(`State-Aware Sticky Headers Expectations: ${demoName}`, () => { const layoutChanged = stuck !== unstuck; const parentOverflowAnchor = await container.evaluate(el => - getComputedStyle(el.parentElement).overflowAnchor + getComputedStyle(el.parentElement!).overflowAnchor ); if (layoutChanged) { From 9a48eca4dc54212d40b709767b9f320998fe5bf5 Mon Sep 17 00:00:00 2001 From: Rick Viscomi Date: Wed, 5 Aug 2026 10:04:07 -0400 Subject: [PATCH 10/11] Move state-aware-sticky-headers to ui-atoms and update CODEOWNERS --- CODEOWNERS | 2 +- .../{scroll => ui-atoms}/state-aware-sticky-headers/demo.html | 0 .../state-aware-sticky-headers/expectations.md | 0 .../{scroll => ui-atoms}/state-aware-sticky-headers/grader.ts | 0 guides/{scroll => ui-atoms}/state-aware-sticky-headers/guide.md | 0 .../state-aware-sticky-headers/negative-demo.html | 0 .../state-aware-sticky-headers/tasks/task.md | 0 7 files changed, 1 insertion(+), 1 deletion(-) rename guides/{scroll => ui-atoms}/state-aware-sticky-headers/demo.html (100%) rename guides/{scroll => ui-atoms}/state-aware-sticky-headers/expectations.md (100%) rename guides/{scroll => ui-atoms}/state-aware-sticky-headers/grader.ts (100%) rename guides/{scroll => ui-atoms}/state-aware-sticky-headers/guide.md (100%) rename guides/{scroll => ui-atoms}/state-aware-sticky-headers/negative-demo.html (100%) rename guides/{scroll => ui-atoms}/state-aware-sticky-headers/tasks/task.md (100%) diff --git a/CODEOWNERS b/CODEOWNERS index f90a03e88..9cfcd06d5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,9 +30,9 @@ guides/security/passkey-reauthentication/ @agektmr guides/security/passkey-registration/ @agektmr guides/security/passkeys/ @agektmr # Owned via feature "container-scroll-state-queries" -guides/ui-atoms/context-sensitive-sticky-headers/ @bramus guides/ui-atoms/scroll-position-aware-elements/ @bramus guides/ui-atoms/scrollability-affordance-hints/ @bramus +guides/ui-atoms/state-aware-sticky-headers/ @bramus # Owned via group "scrolling" guides/ui-atoms/carousel-slide-effects/ @bramus guides/ui-atoms/pull-to-reveal/ @bramus diff --git a/guides/scroll/state-aware-sticky-headers/demo.html b/guides/ui-atoms/state-aware-sticky-headers/demo.html similarity index 100% rename from guides/scroll/state-aware-sticky-headers/demo.html rename to guides/ui-atoms/state-aware-sticky-headers/demo.html diff --git a/guides/scroll/state-aware-sticky-headers/expectations.md b/guides/ui-atoms/state-aware-sticky-headers/expectations.md similarity index 100% rename from guides/scroll/state-aware-sticky-headers/expectations.md rename to guides/ui-atoms/state-aware-sticky-headers/expectations.md diff --git a/guides/scroll/state-aware-sticky-headers/grader.ts b/guides/ui-atoms/state-aware-sticky-headers/grader.ts similarity index 100% rename from guides/scroll/state-aware-sticky-headers/grader.ts rename to guides/ui-atoms/state-aware-sticky-headers/grader.ts diff --git a/guides/scroll/state-aware-sticky-headers/guide.md b/guides/ui-atoms/state-aware-sticky-headers/guide.md similarity index 100% rename from guides/scroll/state-aware-sticky-headers/guide.md rename to guides/ui-atoms/state-aware-sticky-headers/guide.md diff --git a/guides/scroll/state-aware-sticky-headers/negative-demo.html b/guides/ui-atoms/state-aware-sticky-headers/negative-demo.html similarity index 100% rename from guides/scroll/state-aware-sticky-headers/negative-demo.html rename to guides/ui-atoms/state-aware-sticky-headers/negative-demo.html diff --git a/guides/scroll/state-aware-sticky-headers/tasks/task.md b/guides/ui-atoms/state-aware-sticky-headers/tasks/task.md similarity index 100% rename from guides/scroll/state-aware-sticky-headers/tasks/task.md rename to guides/ui-atoms/state-aware-sticky-headers/tasks/task.md From edf49b5289168120c234849d7f7610b57c99ff9a Mon Sep 17 00:00:00 2001 From: Rick Viscomi Date: Wed, 5 Aug 2026 10:05:59 -0400 Subject: [PATCH 11/11] Auto-generate README.md for state-aware-sticky-headers --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 46054f2d5..051a8bf9f 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ _View an example:_ [the `navigation-drawer` guide](https://github.com/GoogleChro
-130 real-world developer use cases +131 real-world developer use cases

accessibility

@@ -252,6 +252,7 @@ _View an example:_ [the `navigation-drawer` guide](https://github.com/GoogleChro - **[scroll-progress-indicator](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/ui-atoms/scroll-progress-indicator.md)**: Create a scroll progress bar, stepped progress tracker, or any visual affordance that communicates how far through a page or section the user has scrolled. - **[scrollability-affordance-hints](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/ui-atoms/scrollability-affordance-hints.md)**: Build scroll-shadow overlays, gradient fades, or directional arrow indicators that appear only when there's actually more content to scroll to in that direction. - **[shrinking-header-on-scroll](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/ui-atoms/shrinking-header-on-scroll.md)**: Smoothly animate a fixed header or full-page cover on scroll to dynamically shrink, gain shadows, and transform its layout over a predefined scroll distance. +- **[state-aware-sticky-headers](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/ui-atoms/state-aware-sticky-headers.md)**: Build sticky section headers or navbars that visually transform when they're actually "stuck" at the top, changing their color scheme or gaining a shadow.

ui-behaviors