From 388701f09cac3a128b4f3ffb3ea99301c0807622 Mon Sep 17 00:00:00 2001 From: Patrick Kettner Date: Sat, 18 Apr 2026 14:19:10 -0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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: