Toggle `position: sticky` to `position: fixed` on Scroll
Toggle `position: sticky` to `position: fixed` on Scroll ź“ė Ø
Itās quite an unusual look when you see an element glide along itās parent element as position: fixed;, the slide right on out of it, as if the positoning of it somehow magically changes at just the right moment, to position: sticky;. This is exactly what weāre going to pull of here with the help of scroll-driven animation and scroll state queries.
Both sticky and fixed positioning are about locking an element to a point on screen where it stays stuck throughout scrolling. A sticky element is stuck within its scrollable ancestor, and a fixed element sticks to the viewport. Both great for user interfaces that have to be persistent, like alert banners. They also make for nice visual effects.
Switching between these two types of position can give the illusion of an element breaking out of its scrollable container while the user is scrolling the page. Hereās an example:
Letās see the mechanism behind that change.
The Layout
<div class="scrollPort">
<div class="visualBlock">
<div class="stickyElement"></div>
</div>
<!-- more blocks -->
</div>
.scrollPort {
/* etc. */
overflow-y: auto;
.visualBlock {
/* etc. */
.stickyElement {
position: sticky;
top: 40px;
}
}
}
The .scrollPort is a scroll container with a set of .visualBlocks that overflow the container. Each .visualBlock has a sticky element inside.
Sizing the Sticky Element
Fixed units for the dimensions of the sticky element wonāt be a problem, but if they have to be relative, there are some precautions to take.
.visualBlock {
/* etc. */
container-type: inline-size;
.stickyElement {
/* etc. */
/* Sets the width to 80% of the query container's (.visualBlock) width */
width: 80cqw;
}
}
We canāt use a percentage (like 80%) to size the sticky element relative to its parent, because the reference element for a percentage unit is its nearest parent, which changes when the element goes from sticky to fixed[1].
To use the same reference for relatively sizing the sticky element, even when it becomes fixed, use container query units:
- Establish the
.visualBlockas aninline-size[2] query container - Use
cqwunit for.stickyElementās width
With sizing done, we move onto the code to change the position value.
Method 1: Using Scroll-Driven Animation
We use CSS view() function to run a keyframe animation thatāll turn .stickyElement from sticky to fixed.
.visualBlock {
/* etc. */
--stickyPosition: sticky;
animation: toFixed;
animation-timeline: view(block 0% 100%);
.stickyElement {
/* etc. */
position: var(--stickyPosition); /* previously, position: sticky; */
}
}
@keyframes toFixed {
to {
--stickyPosition: fixed;
}
}
The parts above:
--stickyPosition: sticky;ā Set a CSS variable in.visualBlockwith an initial value ofsticky. This value is used by.stickyElementto set itsposition.animation: toFixed;ā Apply the CSS animationtoFixed(explained later) to.visualBlock.animation-timeline: view(block 0% 100%);ā The animationās progress is based on.visualBlockās visibility within.scrollPort. It starts when.visualBlockscrolls into view (0%) and ends (100%progress) when it scrolls out of view.toFixedā At the end[3] (to) of the animation progress set--stickyPositiontofixed.
Weāre not done yet, but hereās how it works when toFixed animation is applied through view():
A couple of things to take care of. First, when .stickyElement turns fixed it shifts slightly, since its top is no longer relative to .visualBlock. Needs reassigning the correct top value to prevent the shift.
Second, .stickyElement reverts to sticky when its .visualBlock goes off-screen, which is too soon since we want it to reach the next .stickyElement. Time to expand the area tracked for the view timeline to include the space between .visualBlocks and above .stickyElement.
Iāll keep these values is CSS variables for ease of update.
.scrollPort {
/* etc. */
container-type: size;
.visualBlock {
/* etc. */
--visualBlockMargin: 60px;
--stickyPosition: sticky;
--stickyMarginTop: 50px;
--stickyTopTemp: 40px;
--stickyTop: var(--stickyTopTemp);
margin: var(--visualBlockMargin) auto;
/* the space between .visualBlocks */
animation: toFixed;
animation-timeline: view(block calc(-1 * (var(--visualBlockMargin) + var(--stickyMarginTop))) 100%);
/* includes the space above .visualBlock and .stickyElement */
.stickyElement {
/* etc. */
margin: var(--stickyMarginTop) auto auto;
/* the space above .stickyElement */
position: var(--stickyPosition);
top: var(--stickyTop);
}
}
}
@keyframes toFixed {
to {
--stickyPosition: fixed;
--stickyTop: calc(50vh - 50cqh + var(--stickyTopTemp) - var(--stickyMarginTop));
/* includes the space above .scrollPort and .stickyElement */
}
}
Note
Negative inset values in view() expand the elementās visibility range outward from the boundary edges.
Hereās the result:
This is the method used in our first example, shown at the beginning of the article.
Method 2: Using Scroll State Queries
The second method, using scroll state queries, is the most efficient way to achieve what we want. The only downside is that scroll state queries are not widely supported by browsers yet.
We donāt need a keyframe animation for this one. What we need is a sticky scroll state container.
<div class="scrollPort">
<div class="visualBlock">
<div class="stickyWrapper">
<div class="stickyElement"></div>
</div>
</div>
<!-- more visual blocks -->
</div>
.stickyWrapper {
/* etc. */
container-type: scroll-state;
position: sticky;
--stickyTop: 40px;
top: var(--stickyTop);
.stickyElement {
/* etc. */
}
}
Note
A scroll state container lets its descendants use scroll state queries to apply styles based on the containerās scrolling state.
Thatās why we use a .stickyWrapper to provide the sticky positioning and be used as the scroll state query container.
When .stickyWrapper gets stuck, weāll turn its child, .stickyElement, to fixed.
@container scroll-state(stuck: top) {
.stickyElement {
position: fixed;
top: calc(50vh - 50cqh + var(--stickyTop));
}
}
Hereās how it looks:
As you can see, this method requires much less code in CSS. But since view() is widely supported at the moment, compared to scroll state queries, itās good to have the first method available, too. Choose whichever method or design you want. The key for this to work is to simply maintain the right size and position for the element when it shifts back and forth between its sticky and fixed behavior to look like itās moving between the visual blocks.
Uses and Variants
If thereās a visual element thatās not to be unnecessarily shown to the user right off the bat, but once shown could be useful to keep it on screen, toggling its position like the examples in this post might do the trick. It could be a call-to-action button, or a banner, or it could be graphics moving between slides in a presentation once a particular slide is shown.
On top of the position change, if other visual changes are layered, that opens up even more variations for how this can play out.
As mentioned before, focus on where and how you want the element to appear when its sticky and when its fixed, for the desired effect to come through as the position changes on scroll.