Sticky and Snapped Elements With CSS Scroll-State Queries
A shadow that appears only when a header sticks feels like a styling decision. I like that CSS scroll-state queries can describe it directly, without a script maintaining a separate “stuck” class.
The MDN scroll-state query guide covers sticky and snapped elements, as well as scrollable containers. The useful distinction is the state being queried: a sticky element’s edge, a snap target, or the directions available for scrolling.
Add a shadow when a sticky heading sticks
A sticky heading needs an offset and room to move inside its scrolling area. This small panel supplies both:
<section class="demo">
<header class="sticky">
<div class="sticky-inner">Section title</div>
</header>
<p class="demo-content">Scroll this panel to move the content past the title.</p>
</section>
.demo {
max-height: 12rem;
overflow: auto;
}
.demo-content {
min-height: 24rem;
}
.sticky {
position: sticky;
top: 0;
container-type: scroll-state;
}
.sticky-inner {
padding: 0.75rem;
background: white;
}
@container scroll-state(stuck: top) {
.sticky-inner {
box-shadow: 0 0.25rem 0.5rem rgb(0 0 0 / 0.08);
}
}
The header stays sticky independently of the query. Supporting browsers add the shadow when the header is stuck to the top edge.
The white background is part of the base styling, so content cannot show through the heading when it overlaps. I would keep that readability decision outside the enhancement; the shadow is the optional part.
Scroll-state queries style a container’s descendants
The container-type: scroll-state value
makes an element available for scroll-state queries. In the heading example, .sticky is the container and .sticky-inner receives the decoration.
The container query model applies the matching styles to descendants. The element whose state is queried cannot use that same query to style itself.
That extra inner element may look a little fussy for a shadow. I would still prefer it to a script whose only job is toggling a presentation class, when the required browsers support the query.
Container names can help when nested components have several possible query containers. For a small isolated heading, the unnamed query keeps the example readable.
Show a hint while more content is available
A scrollable query tests whether more content can be reached in a direction. The following panel keeps the hint inside the container whose state is queried:
<div class="scroll-panel" tabindex="0" role="region" aria-label="Scrollable notes">
<div class="scroll-panel__content">
<p class="scroll-hint" aria-hidden="true">More below ↓</p>
<p>Notes continue through this panel.</p>
<p>Scroll to the bottom to reach the end.</p>
</div>
</div>
.scroll-panel {
max-height: 12rem;
overflow: auto;
container-type: scroll-state;
}
.scroll-panel__content {
min-height: 24rem;
}
.scroll-hint {
position: sticky;
top: 0;
background: white;
opacity: 0;
pointer-events: none;
}
@container scroll-state(scrollable: bottom) {
.scroll-hint {
opacity: 1;
}
}
The hint is available while the panel can scroll farther down. At the bottom, the query no longer matches and the hint becomes transparent.
Without query support, the hint stays transparent and the panel remains scrollable. I would treat the text as a small visual cue, rather than the only indication that more content exists. The hint is not an interactive control.
Highlight the snapped card without hiding its text
A snapped query applies to a snap target inside a scroll snap container. Each card therefore becomes a scroll-state container, while its inner element receives the highlight:
<div class="gallery" tabindex="0" role="region" aria-label="Example gallery">
<article class="card"><div class="card-inner">First card</div></article>
<article class="card"><div class="card-inner">Second card</div></article>
<article class="card"><div class="card-inner">Third card</div></article>
</div>
.gallery {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.card {
flex: 0 0 80%;
scroll-snap-align: center;
container-type: scroll-state;
}
.card-inner {
padding: 1rem;
border: 2px solid transparent;
}
@container scroll-state(snapped: x) {
.card-inner {
border-color: #4338ca;
}
}
The snapped card gets a coloured border. Every card keeps its text visible, whether it is snapped or not.
I would start with that kind of restrained highlight. Revealing essential details only after a card snaps makes the content depend on a scrolling interaction that the reader may never perform.
Scroll direction is different from distance scrolled
The scroll-state descriptors describe different conditions:
| Descriptor | What it checks |
|---|---|
stuck | Whether a sticky container is stuck to a specified edge. |
snapped | Whether the container is a selected snap target on a specified axis. |
scrollable | Whether scrolling farther in a specified direction is possible. |
scrolled | The direction in which the container was most recently scrolled. |
scrolled does not simply mean “the reader has moved away from the start”. To check whether content is available above the current position, scrollable: top describes that condition more directly.
I would choose the query from the actual design requirement. A header reacting to scroll direction and a shadow reacting to sticky positioning may look related, but they ask different questions.
Keep presentation in CSS without assuming a speed gain
MDN notes that scroll events can fire at a high rate . A listener that repeatedly checks positions is more code to maintain for a purely visual effect.
That is why I find scroll-state queries appealing here. The stylesheet states the condition and its visual result together.
That preference is not a benchmark. JavaScript remains appropriate when scrolling needs to update application behaviour, and using CSS does not by itself prove a performance improvement.
Keep the base component usable
Support for the different scroll-state features needs checking separately; ordinary size-container-query support does not establish support for scroll-state queries. The container-type compatibility table and individual query descriptors are the relevant starting points.
The heading, panel, and gallery above have base styles outside their queries. Unsupported state rules leave the content available, with an optional effect missing.
An @supports (container-type: scroll-state) guard can check the container declaration, but it does not prove every descriptor is implemented. I would keep navigation and essential information usable without those rules, then add a shadow or highlight where the browser can supply it.
Filed under CSS on
Enjoyed this article?
If you found it useful, share it with a friend or someone who might find it helpful too. Thanks for reading!
Related articles
- CSS border-shape Can Draw Boxes clip-path Cannot
CSS border-shape lets borders and shadows follow a custom outline. Compare it with clip-path, try complete examples, and keep a usable rounded fallback.
- CSS corner-shape Goes Beyond Rounded Corners
CSS corner-shape adds bevels, scoops, and squircles to ordinary boxes. See how it works with border-radius, normal borders, and a rounded-card fallback.
- CSS Calculations With sibling-index() and sibling-count()
Use CSS sibling-index() and sibling-count() for staggered animations and position-based calculations, with working examples and browser support checks.