Debug panel

Close debug panel
Roma’s Unpolished Posts

Observation: Sticky Anchor Navigation

Published on:
Categories:
Observation 9, CSS 61
Current music:
Paranoid void — The end of the travel,The beginning of the world
Current drink:
Camomile tea

Introduction

When I updated how I am doing scroll markers for the table of contents in this blog, I stumbled upon one curious interaction that I previously did not think deeply about.

That interaction: how anchor navigation behaves with elements that have sticky positioning.

Look at this example, and try clicking on the “Go to” links, including clicking the same link multiple times.

A
B
C
D
E
F
.scrollport {
	overflow: auto;
	overflow-x: hidden;
	scroll-behavior: smooth;
	height: 200px;
	outline: 1px solid var(--GREEN);
}
.test {
	position: sticky;
}
Trying to go to different anchors often results in the page scrolling just a bit, making it so you often can’t reach the desired destination with just one navigation.

Isn’t this curious?

What’s Going On?

This behavior is (mostly) interoperable across all browsers, and is defined in the specs, in the Scroll Position of Sticky-Positioned Boxes section of the CSS Positioned Layout Module Level 3 specification:

For the purposes of any operation targeting the scroll position of a sticky positioned element (or one of its descendants), the sticky positioned element must be considered to be at its offsetted position.

The spec also provides this non-normative example:

For example, if a user clicks a link targeting a sticky-positioned element, if the element’s nearest scrollport is currently scrolled such that the sticky positioned element is offset from its initial position, the scroll container will be scrolled back only the minimum necessary to bring it into its desired position in the scrollport (rather than scrolling all the way back to target its original, non-offsetted position).

When we want to scroll from some position in the scrollport to some element, essentially, we need to have two points: start and end.

What the spec says: both points are considered regardless of the element’s original position. Instead of using the original position, the browser will pretend that everything is static, but as if the current visible position of elements is the truth. Then it will look at the difference between the element’s current position in the scrollport, and calculate the distance we’d want to scroll to put that element to the target position (“scroll snap area”, which is affected by scroll-margin, for example).

Can This Be Useful?

Initially, this behavior baffled me, and I considered this a bug. After discovering that this repeats across all browsers, I had to dig a bit deeper, which resulted in this post.

But I wondered: are there any legit use cases for this weird behavior?

I came up with one: something like a “page down” and “page down” links for some scrollable element. Or, in the below example, “page left” and “page right” — I implemented this for horizontal scrollport because navigation to elements inside nested scrollports is even weirder, and is outside the scope for this post.

Page Left Page Right
Start End
.example2-scrollport {
	overflow: hidden;
	overflow-x: auto;
	container-type: inline-size;
}

#page-left {
	position: sticky;
	left: -100cqw;
}

#page-right {
	position: sticky;
	right: -100cqw;
}
Navigation to the “Page left” or “Page right” scrolls the container by exactly its width.

This kinda works? I am unlikely to use this in production, and won’t recommend you as well, but hey.

The way it works is simple: if we know the “size” of a page, we can use a zero-sized sticky element in the corresponding direction, and then set its inset to a negative value equal to this desired page size.

What If I Don’t Want It?

In my case, this was not a desired behavior. What I wanted was to scroll to the original position of the element. Can we somehow achieve this while keeping the sticky positioning?

We can, using one of my recent favorites: a one-time animation. Below is the first example, modified to keep the sticky position, but with its navigation to now properly scrolling up to the original position of such element.

A
B
C
D
E
F
@keyframes --unstuck {
	from {
		position: static;
	}
}

.example--fixed {
	&:not(:active) .test:target {
		animation: --unstuck 0.01s 0s both;
	}
}
Navigating to any of the anchors leads to the correct position.

This mostly helps: clicking on one of the links scrolls the page to the correct place. The only issue is with the “B” element, which might visually jump for a split second. This is because, for positive top value, in this case, the element will never be at its original position. So, when we temporarily disable the sticky positioning, it “returns” there, but then immediately goes back to being sticky. Could this specific issue be the reason this aspect was specified the way it is?

Of course, we could also just throw away sticky when the element has :target, but then for any scroll following this navigation the element won’t be sticky, which is usually not what we expect.

Final Words

I hope that now, if you stumble over a similar issue in your project, you will know why it happens and how it can be potentially fixed.

There are still so many things that are weird about anchor navigation, especially with nested scrollports, with many interop issues. But that’s a topic for another post.

Please share your thoughts about this on Mastodon!