Skip to content

[css-conditional-5] Anchor fragment scrolls should update scroll-state(scrolled) direction to stay consistent with scroll-padding #13787

@brechtDR

Description

@brechtDR

The resolution in #12394 categorizes anchor fragment scrolls as absolute scrolls, meaning they don't set the last scroll direction for state queries like scroll-state(scrolled: top) and scroll-state(scrolled: bottom).

I believe this creates a real problem when combining two CSS features that are designed to work together:

scroll-padding-block-start offsets the scroll target so it isn't obscured by a sticky/fixed header
@container scroll-state(scrolled) shows/hides a header based on scroll direction (the "hidey-bar" pattern)

If anchor fragment scrolls don't update scroll-state(scrolled), the header's visibility state becomes stale after anchor navigation, while scroll-padding still reserves space based on the assumption that the header is present. These two features go out of sync.

Real-world example
On my site utilitybend.com, I use the following CSS pattern.

scroll-padding accounts for the sticky header:

html,
body {
  scroll-padding-block-start: var(--c-header-height);
}

The header hides/shows using scroll-state(scrolled):

html {
  container-type: scroll-state;
}
@container scroll-state(scrolled: top) {
  .header-container {
    translate: 0 0;
  }
}
@container scroll-state(scrolled: bottom) {
  .header-container {
    translate: 0 -100%;
  }
}

Blog articles include a table of contents with anchor links to headings (e.g. <a href="#section-name">).

Steps to reproduce the issue

Open any long article on utilitybend.com in Chrome

The good

Scroll down... the header hides via scroll-state(scrolled: bottom) ✅

The not so good...

Click a TOC anchor link that targets a heading further down (or up) the page
The browser scrolls to the anchor target, applying scroll-padding-block-start to offset for the header

Expected: The anchor scroll updates scroll-state(scrolled) based on its direction. The header responds (e.g., shows if it was hidden), and scroll-padding correctly compensates for the header's actual visibility.

Actual: The anchor scroll is treated as an absolute scroll and does not update scroll-state(scrolled). The header stays in whatever state it was in. If it was hidden, scroll-padding-block-start still reserves space for a header that isn't visible, creating an unexplained gap above the anchor target.

Chromium bug for reference: https://issues.chromium.org/issues/491263691

Why anchor fragment scrolls should set scroll direction
I understand the reason behind treating scrollTo, scrollIntoView, and programmatic absolute scrolls as directionless, they are typically code-initiated side-effects where scroll direction is incidental. But anchor fragment navigation is different:

  • It is always user-initiated. Clicking an <a href="#id"> link is an explicit navigation action by the user, not a programmatic adjustment. The user is actively choosing to go somewhere on the page.
  • The direction has intent... The browser scrolls the viewport toward the target. That movement has a concrete direction along the block axis, the inline axis, or both, depending on the layout. The direction is determined by the relative position of the current scroll offset and the anchor target.
  • scroll-padding depends on header visibility. The scroll-padding property exists precisely for the case where a sticky header would obscure an anchor target. If the header's visibility is driven by scroll-state(scrolled), and anchor scrolls don't update that state, the two features work against each other. The scroll-padding reserves space for a header that may not be visible (or vice versa).

User expectation. When a user clicks an anchor, they expect the page to behave as if they scrolled there. A "hidey-bar" header that responds to manual scrolling but ignores anchor navigation creates an inconsistent experience — the header seems to arbitrarily freeze in place.

Proposed change

In the scroll type categorization from #12394, anchor fragment scrolls should be reclassified as relative (directional) scrolls rather than absolute scrolls, at least with respect to how they affect scroll-state(scrolled) direction queries. This would mean:

An anchor scroll downward triggers scroll-state(scrolled: bottom)
An anchor scroll upward triggers scroll-state(scrolled: top)
scroll-padding and scroll-state(scrolled) stay in sync

Alternatively, if the working group prefers to keep anchor scrolls as absolute for other purposes (e.g., scroll snap selection), a carve-out could be made specifically for scroll-state(scrolled) direction queries: "Anchor fragment scrolls, while treated as absolute for scroll snap purposes, set the last scroll direction for scroll-state queries based on the direction of the resulting scroll."

With the current behavior, I truly believe that it causes UX problems and makes some of these newer features less powerful.

I tried to upload a video directly in this issue, but it didn't seem to work. You can find a video demonstrating that issue here.

As a sidenote to this issue, a physical dragging on the scrollbar also does not allow the header to hide/show in this case. It's less of an issue for me in this case, still, I'd like that to work as well: This issue tells about that

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions