CSS Scroll-State Queries for Sticky UI
Style stuck, snapped, and scrollable state from actual layout while keeping unsupported navigation complete.
CSS scroll-state queries let a component style descendants from what scrolling is actually doing, not from a guessed pixel threshold in JavaScript. The useful result is subtle: a sticky contents rail gains elevation only while stuck and reveals overflow cues only when more content can be scrolled.
This tutorial builds that enhancement for an article layout while preserving a complete baseline in browsers that do not support the queries.
CSS scroll-state queries style the state you have
A table of contents uses position sticky beneath the site header. In the baseline, it is a plain labeled navigation with visible links and a maximum height that can scroll. The enhancement adds a border and compact header while the rail is stuck, an end fade only when content remains below, and a marker when a section link is snapped into place. CSS scroll-state queries express those conditions without a scroll listener copying browser state into classes.
The distinction matters for responsive layouts. Sticky may never engage on a short page or wide desktop, and overflow may disappear after zoom or translation changes. A fixed threshold assumes geometry the browser already knows. Querying the container lets the style follow actual layout while keeping semantics in HTML.
Sticky UI should remain useful in its ordinary layout before any query matches. CSS scroll-state queries then communicate a detected relationship rather than repairing missing structure, labels, or keyboard access.
Treat the feature as progressive and evolving
The CSS Conditional Rules draft is the normative work-in-progress source, MDN's container scroll-state query guide offers implementer-facing examples, and the CSSWG scroll-state explainer clarifies use cases. Record the browser versions and exact syntax tested because draft details and support can change.
Start from useful sticky UI before any query. Unsupported browsers should retain navigation, keyboard reachability, current-page naming, scrollability, and visible focus. The enhancement may add spatial context and polish; it must not unlock content or controls. Use feature queries to contain experimental declarations so an unknown value does not disturb the stable position rule.
Keep the syntax behind a feature query and document the tested engine versions. A stuck container query is an enhancement; the contents rail still needs stable positioning, contrast, and links when the feature is absent.
Declare the right scroll-state container
A query observes a named container and styles descendants, so place the container on the element whose state matters and the responsive treatment on an inner wrapper. The sticky rail owns stuck state; its contents wrapper receives shadow, background, and padding. Naming prevents a nested card or unrelated ancestor from accidentally becoming the query source. Keep size or style container responsibilities separate when that makes ownership easier to inspect.
CSS scroll-state queries do not let an element style arbitrary ancestors. That apparent limitation encourages a useful component structure: state owner outside, visual skin inside. If the entire parent layout must react, reconsider whether the state belongs in CSS alone or whether the markup can expose a proper descendant target. Avoid extra wrappers that have no semantic or layout role beyond working around an imagined selector.
Name the container nearest the geometry being observed and avoid accidental nested ownership. DevTools inspection should reveal which scrollport supplies state, which descendant consumes it, and which ancestor establishes stickiness.
Use stuck state for context, not spectacle
When the rail reaches its sticky inset, add a modest surface, border, or shadow that explains separation from moving content. Do not animate a dramatic scale change at the exact sticking boundary; it can look like layout movement and distract during reading. The label remains text, and elevation is redundant context rather than the only stuck indicator. Keep focus rings above any overflow clipping introduced by the skin.
The failure mode is styling from an IntersectionObserver sentinel that disagrees with the actual sticky constraint after header height or writing mode changes. Its consequence is a floating shadow at the wrong time. CSS scroll-state queries use the layout engine's own state, reducing duplicated geometry. Still test top and bottom sticking, nested scroll containers, safe-area insets, and both block directions supported by the product.
Use stuck state to add a restrained divider, background, or context label. The change should help a reader understand that the rail is anchored without turning ordinary scrolling into continuous decoration.
Reveal scrollability without hiding the last item
A long contents list gets a fixed maximum block size and ordinary overflow. When it can scroll toward the end, an inner pseudo-element or dedicated decorative element may show a gradient and text hint. Remove that hint when the list reaches its end. If scrollability exists toward the start after the reader moves, a matching cue can appear there. The navigation itself keeps a visible scrollbar where platform conventions or user settings expect one.
Do not place the gradient over clickable text or communicate hidden items by color alone. Leave padding beneath the final link, and mark decoration unavailable to assistive technology. CSS scroll-state queries should make discoverability better, but the semantic list and its focusable anchors remain complete with styles removed. At 200-percent zoom, verify the overflow region does not trap page scrolling or keyboard focus.
Scrollable state can expose a textual “more below” cue and fade only nonessential decoration. Preserve the final link, scrollbar access, and sufficient padding so the hint never looks like clipped content.
Use snapped state only with real scroll snapping
Snapped queries belong to components already using scroll snap for a defensible interaction, such as a horizontal chapter strip. A descendant can emphasize the item that currently defines the snapped state, but selected navigation still needs appropriate semantic state when it represents the current section. Snapping is geometry; selection is product meaning. They may align during a stable scroll, yet should not be treated as synonyms in the accessibility tree.
For the article rail, keep snap optional and avoid forcing vertical reading into discrete jumps. A small mobile chapter strip can use inline snapping while the desktop contents list does not. CSS scroll-state queries allow both compositions to share semantic links and diverge only in responsive presentation. Respect reduced motion when adding any scroll behavior and preserve direct anchor navigation.
Snapped state describes a completed snap relationship, not merely an item near the viewport center. Test keyboard, touch, reduced motion, and content resizing before using it to mark the active step.
| State | Enhancement | Baseline remains |
|---|---|---|
| Stuck | Elevation + compact label | Sticky navigation |
| Scrollable end | Lower fade + text cue | Scrollbar + links |
| Scrollable start | Upper cue | DOM order |
| Snapped | Geometric emphasis | Current semantics |
Layer experimental CSS behind the baseline
Put baseline layout and component tokens in established cascade layers, then add the enhancement in a later, named experimental layer whose selectors remain low-specificity. This makes removal easy if syntax or support changes. Avoid duplicating every baseline declaration inside the feature query; only override the visual differences caused by a true state. Custom properties can centralize stuck elevation and fade size without making the state global.
The stylesheet should remain understandable when the enhancement block is deleted. That is my progressive-enhancement test. CSS scroll-state queries deserve adoption when they remove observation code and improve truthful feedback, not when they require a parallel component. Keep a screenshot pair with the feature forced on and unavailable so review includes both modes.
Place experimental declarations after a complete baseline layer and keep specificity modest. Removing the entire enhancement block should not alter document order, link targets, focus behavior, or the ability to reach every section.
- 1Baseline
Build complete sticky navigation.
- 2Contain
Name the scroll-state owner.
- 3Query
Style descendants from true state.
- 4Disable
Verify the same task without support.
Test geometry instead of one screenshot
Exercise short and long articles, short and long contents, 320 through 1440 CSS pixels, 200-percent zoom, increased text spacing, right-to-left inline flow, a tall dynamic header, nested scrolling, anchor jumps, keyboard traversal, touch momentum, and print. Record whether stuck, scrollable, and snapped treatments correspond to visible state. Disable support and repeat the critical navigation path.
Continue with CSS scroll-marker carousels, restrained scroll-driven animation, container-query cards, and CSS cascade layers. Together they separate container geometry, scroll position, motion, and override policy. A passing visual is one where each CSS mechanism owns one relationship and the HTML remains useful alone.
Build geometry cases for short and long pages, zoom, vertical writing, nested scrolling, dynamic banners, and safe-area changes. A single screenshot cannot demonstrate when the sticky threshold starts or ends.
Runnable artifact: The dependency-free fallback model proves that unsupported browsers keep the sticky position and label while merely omitting the state-dependent shadow. Save it as scroll-state-fallback.test.mjs and run node scroll-state-fallback.test.mjs. Expected final line: PASS: scroll-state fallback stable.
import assert from "node:assert/strict";
const view=({supports,stuck})=>({position:"sticky",shadow:supports&&stuck?"raised":"flat",label:"Contents"});
assert.deepEqual(view({supports:false,stuck:true}),{position:"sticky",shadow:"flat",label:"Contents"});
assert.equal(view({supports:true,stuck:true}).shadow,"raised");
console.log("PASS: scroll-state fallback stable");
Ship a small state contract
Document the queried container, supported states, descendant targets, baseline behavior, enhancement behavior, browser matrix, reduced-motion treatment, scrollbar policy, and known nested-scroll limits. Include a tiny fixture with toggles for content length, viewport, direction, zoom approximation, and support flag. The fixture should make every state reachable without a production page or special timing.
CSS scroll-state queries are best understood as truthful conditional styling. They let sticky UI acknowledge when it is stuck, scroll regions reveal when more content exists, and snapped items reflect actual geometry. Keep the changes redundant, local, and removable. The technique then replaces fragile scroll bookkeeping with CSS while preserving the quiet, accessible reading experience that justified the component in the first place.
The enhanced contents rail now responds to real geometry without becoming dependent on it. CSS scroll-state queries add stuck elevation, remaining-content cues, and optional snapped emphasis, while the unsupported experience remains a labeled, keyboard-reachable, scrollable navigation. Every cue is redundant with text, position, or ordinary scrollbar behavior, so losing the enhancement does not hide an action or section.
Keep the fixture small enough for a designer to resize and inspect during review. It should reach normal, stuck, scrollable-at-start, scrollable-at-end, and snapped states, then reproduce the same navigation with feature support disabled. Test writing direction, zoom, dynamic header size, and nested scroll ownership before relying on a polished desktop capture. Revisit the rules when the draft syntax, browser implementations, article shell, safe-area inset, or contents length changes, because each can move the state boundary.