Your site header pins to the top of the viewport on scroll, and the design calls for the obvious next thing: a hard shadow under it once it’s pinned, and nothing before that.
So you go looking for the selector, and there isn’t one. The header’s rules don’t change when it pins. Its class list doesn’t change. Its computed style is identical to what it was two pixels of scrolling ago, because sticking isn’t a state the style system ever enters — it’s an offset applied to a box that layout already finished describing.
There are three ways out of that, and they arrived in roughly the wrong order: a JavaScript sentinel that everyone copies and few get right, a CSS feature that does it properly in one engine family, and a decision about which of those your project can live with. Each fails differently, and the failures are worth knowing before you pick.
The State That Doesn’t Exist
A sticky element has one set of styles and two positions. Before the threshold is crossed it sits in flow. After, it’s offset from that flow position by exactly the distance needed to hold it at the inset you gave it. Nothing about the element’s cascade changes between those two moments.
That’s why this doesn’t exist, and never did:
.site-header {
position: sticky;
top: 7rem;
}
/* there is no such pseudo-class */
.site-header:stuck {
box-shadow: 0 6px 0 var(--ink);
}
The reason isn’t that nobody wanted it. It’s that a naive :stuck is circular. Styling the stuck state can change the element’s own box — padding, height, borders — and changing the box can change whether the element is still stuck, which re-evaluates the selector, which changes the box again. A layout that oscillates every frame is worse than no feature.
Every workable answer to this problem, including the CSS one further down, is really an answer to that loop. You either watch the state from outside the element, or you forbid the element from styling itself.
The Sentinel Everyone Copies
The standard trick is to stop watching the header and watch an empty element parked where the header lives in flow. Call it the sentinel. When the sentinel scrolls up out of view, the header has left its flow position, which means it’s pinned.
const HEADER_INSET = 112; // must match `top` on .site-header
const header = document.querySelector(".site-header");
const sentinel = document.querySelector(".header-sentinel");
const observer = new IntersectionObserver(
([entry]) => {
const stuck =
!entry.isIntersecting && entry.boundingClientRect.top < HEADER_INSET;
header.classList.toggle("is-stuck", stuck);
},
{ rootMargin: `-${HEADER_INSET}px 0px 0px 0px`, threshold: 0 },
);
observer.observe(sentinel);
Two details in there are the difference between this working and this being a bug report.
The rootMargin pulls the observer’s top edge down by the header’s inset. Without it, the sentinel stays “visible” until it passes the actual top of the viewport, and the header will have been pinned for 112 pixels of scrolling before your class lands. A shadow that appears late is more noticeable than no shadow.
The boundingClientRect.top check handles the other side. An element is also not intersecting when it’s below the viewport — which is where your sentinel is when the user hasn’t scrolled to the header yet, or lands mid-page on a deep link. Checking that the sentinel is above the line distinguishes “scrolled past” from “not reached”.
Observing the header directly instead is the version you’ll find most often, usually with threshold: 1 and a one-pixel negative margin. It leans on the header being clipped by a single pixel once pinned. It works until the inset isn’t zero, and then it never fires at all.
Where The Sentinel Lies
The sentinel has to be a sibling that occupies the header’s flow position without disturbing it. That’s a tighter constraint than it sounds, and modern layout breaks it in a way that’s hard to see.
Breaks
A zero-height div dropped in as a flex or grid child. It has no height, but it still takes a gap, so the layout shifts by one gutter the moment you add it.
Holds
The sentinel is absolutely positioned inside a relative wrapper that sits above the header. It’s out of flow, so no gap, no gutter, no shift.
.header-anchor {
position: relative;
}
.header-sentinel {
position: absolute;
top: 0;
width: 100%;
height: 1px;
}
Three more places it misreports.
If the header sticks inside a scrolling panel rather than the document, the observer needs that panel as its root. Leave root unset and you’re measuring against the viewport, which has no idea the panel scrolled at all.
If the header sticks to the bottom — a toolbar, a mobile action bar — the sentinel goes after it, and the comparison flips. The pattern isn’t symmetric, and copying the top version downward produces a class that’s permanently on.
And the callback is asynchronous by design. It runs after the scroll has painted, so on a fast flick you can see one frame of unshadowed, pinned header. Usually invisible. Occasionally the thing a designer notices in review and you can’t explain.
The element can’t see itself. Something beside it has to.
Internal_Style_Doctrine // §4.1
Stuck Became A Container State
Container queries solved the self-reference problem years ago by rule rather than by cleverness: a container query styles descendants, never the container. Scroll state reuses that exact device.
You mark the sticky element as a scroll-state container, then style something inside it.
.site-header {
position: sticky;
top: 7rem;
container-type: scroll-state;
}
.site-header > .bar {
transition: box-shadow 150ms ease;
}
@container scroll-state(stuck: top) {
.site-header > .bar {
box-shadow: 0 6px 0 var(--ink);
}
}
The rule that catches everyone on the first attempt is in that snippet: .site-header carries container-type, so .site-header is the one element the query can’t restyle. You need an inner element to hold the visual change. If your header is a single flat node, you’re adding a wrapper.
stuck takes an edge — top, right, bottom, left, the logical equivalents, or none for the resting state. The element has to actually be position: sticky and inside a scroll container for the query to evaluate as anything other than none, which means all four of sticky’s usual preconditions still have to hold first. A query that never matches is far more often a sticky element that never pins.
!
Chromium only, as of late 2026. Shipped in Chrome and Edge 133; Firefox and Safari have not implemented it. Gate it with
@supports (container-type: scroll-state)
and check current support before you ship.
The honest framing: this is a progressive enhancement, not a replacement, until two more engines land it. A shadow that appears on pin in Chrome and never appears in Safari is a perfectly acceptable product. A navigation control that only becomes visible when stuck is not.
Pick One On Purpose
Three options, three different things that go wrong with them.
| Approach | Detects the pin when | Fails at |
|---|---|---|
| Observe the header directly | It’s clipped by a pixel at the root edge | Any non-zero inset; silently never fires |
| Sentinel + IntersectionObserver | A sibling in flow leaves the adjusted root | Flex gaps, nested scrollports, one frame of lag |
scroll-state(stuck: …) | The engine knows, at style time | Chromium only; can’t style the container itself |
How I’d decide, in order:
- If the stuck appearance is decoration — a shadow, a border, a tighter row — use the container query alone and let other browsers render the resting state. No JavaScript at all.
- If something functional depends on it, write the sentinel, and write it with the
rootMarginand theboundingClientRectcheck. Both, not one. - If you already shipped the sentinel, keep it, and don’t layer the container query on top. Two sources of truth for one boolean will disagree during a fast scroll, and you’ll debug the disagreement rather than the feature.
What’s worth carrying out of this is smaller than the API surface. Sticky doesn’t have a stuck state, it has a stuck position, and everything above is a different answer to the question of who’s allowed to notice the difference.