CSS Detect a Scrollable Element
Detect whether an element can scroll, and in which direction, using @container scroll-state() or a scroll-driven animation flag.
- Level
- Intermediate
- Published
- Updated
- Reading time
- 8 min
Try it
4 live demos
Your browser can't run this demo
It needs :has(), container queries and scroll-state container queries, which this browser doesn't support yet. It works in Chrome 133+ and Edge 133+. Firefox and Safari don't support it yet.
Needs :has(), container queries and scroll-state container queries
More above More below
- Build finished
- New comment on Pricing
- Invoice paid
- Deploy to staging
- Two new sign-ups
- Backup completed
- Review requested
- Password changed
- Weekly report ready
- Domain renewed
- Cache cleared
- Server restarted
Cues light up where there is more to scroll
Other ways to build it
Your browser can't run this demo
It needs scroll-driven animations, which this browser doesn't support yet. It works in Chrome 115+, Edge 115+, Firefox 159+ and Safari 26+.
Needs scroll-driven animations
OverflowsFits
Build finished
New comment on Pricing
Invoice paid
Deploy to staging
Two new sign-ups
Backup completed
Review requested
Weekly report ready
A flag that works in more browsers
See the codeBuild finished
New comment on Pricing
Invoice paid
Deploy to staging
Two new sign-ups
Backup completed
Review requested
Weekly report ready
Scroll shadows that work everywhere
See the codeYour browser can't run this demo
It needs scroll-state container queries, which this browser doesn't support yet, so you're seeing a plainer version. It works in Chrome 133+ and Edge 133+. Firefox and Safari don't support it yet.
Needs scroll-state container queries
A row that fades only where there is more
See the codeBuild it
8 snippets to copy
CSS Detect a Scrollable Element
HTML
<div class="ds-scroller" tabindex="0" role="region" aria-label="Notifications">
<p class="ds-state">
<span class="ds-cue ds-up">More above</span>
<span class="ds-cue ds-down">More below</span>
</p>
<ul class="ds-list">
<li>Build finished <time>9:41</time></li>
<li>New comment on Pricing <time>9:38</time></li>
<li>Invoice paid <time>9:30</time></li>
<li>Deploy to staging <time>9:12</time></li>
<li>Two new sign-ups <time>8:57</time></li>
<li>Backup completed <time>8:40</time></li>
<!-- ...as many items as you like -->
</ul>
</div>
CSS
/* the scroller is its own scroll-state container */
.ds-scroller {
height: 15rem;
overflow-y: auto;
container-type: scroll-state;
border: 1px solid #2a2a2d;
border-radius: 12px;
background: #141415;
color: #f0f0f0;
font-family: system-ui, sans-serif;
}
/* the cues ride at the top as the list scrolls under them */
.ds-state {
position: sticky;
top: 0;
z-index: 2;
display: flex;
justify-content: flex-end;
gap: .4rem;
margin: 0;
padding: .55rem .75rem;
border-bottom: 1px solid #2a2a2d;
background: #141415;
}
.ds-cue {
padding: .3rem .6rem;
border: 1px solid #2a2a2d;
border-radius: 999px;
color: #88888f;
font: 500 .72rem/1 monospace;
opacity: .5;
transition: opacity .2s, background-color .2s, color .2s;
}
/* a shadow under the cues, and one at the foot of the box */
.ds-state::after {
content: "more above";
position: absolute;
top: 100%;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: flex-start;
height: 2.5rem;
padding-top: .35rem;
box-sizing: border-box;
background: linear-gradient(rgba(184, 255, 87, .45), transparent);
font: 600 .66rem/1 monospace;
letter-spacing: .08em;
text-transform: uppercase;
opacity: 0;
transition: opacity .2s;
pointer-events: none;
}
.ds-scroller::after {
content: "more below";
position: sticky;
bottom: 0;
display: flex;
justify-content: center;
align-items: flex-end;
height: 2.5rem;
margin-top: -2.5rem;
padding-bottom: .35rem;
box-sizing: border-box;
background: linear-gradient(transparent, rgba(184, 255, 87, .45));
font: 600 .66rem/1 monospace;
letter-spacing: .08em;
text-transform: uppercase;
opacity: 0;
transition: opacity .2s;
pointer-events: none;
}
.ds-list {
margin: 0;
padding: .25rem .9rem .5rem;
list-style: none;
}
.ds-list li {
display: flex;
justify-content: space-between;
padding: .65rem 0;
border-bottom: 1px solid #2a2a2d;
}
.ds-list time {
color: #88888f;
font: .75rem/1.7 monospace;
}
/* each direction the box can still scroll lights its cue */
@container scroll-state(scrollable: top) {
.ds-up {
border-color: #b8ff57;
background: #b8ff57;
color: #0c0c0d;
opacity: 1;
}
.ds-state::after {
opacity: 1;
}
}
@container scroll-state(scrollable: bottom) {
.ds-down {
border-color: #b8ff57;
background: #b8ff57;
color: #0c0c0d;
opacity: 1;
}
.ds-scroller::after {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ds-cue,
.ds-state::after,
.ds-scroller::after {
transition: none;
}
}
A flag that works in more browsers
The scroll-driven animation trick sets a custom property that acts as an overflow flag. The box gets animation: can-scroll linear; animation-timeline: scroll(self), where scroll(self) uses the element's own box as the scroller. The keyframes set --can-scroll: 1 at both from and to. When the box overflows, the timeline is active and the property is 1 at every scroll position. Without overflow, the timeline is inactive and the property keeps its default 0. The flag inherits, so a badge inside reads it from opacity: var(--can-scroll) and shows "Overflows" or "Fits" (two words stacked in one grid cell). The box gets a 3px outline via calc(3px * var(--can-scroll)). It answers whether the box overflows at all, not which direction. The trick counts overflow: hidden too, unlike scroll-state queries, and works in Chrome 115+, Firefox 159+ and Safari 26+.
HTML
<div class="dsf-box" tabindex="0" role="region" aria-label="Activity">
<p class="dsf-flag">
<span class="dsf-yes">Overflows</span>
<span class="dsf-no">Fits</span>
</p>
<p>Build finished</p>
<p>New comment on Pricing</p>
<p>Invoice paid</p>
<!-- ...more rows -->
</div>
CSS
.dsf-box {
--can-scroll: 0;
height: 11rem;
overflow-y: auto;
border: 1px solid #2a2a2d;
border-radius: 12px;
background: #141415;
color: #f0f0f0;
font-family: system-ui, sans-serif;
outline: calc(3px * var(--can-scroll)) solid #b8ff57;
outline-offset: 3px;
}
/* the timeline is inactive when there is nothing to scroll, so the
flag only turns to 1 when the box overflows. animation-timeline
must come after the animation shorthand */
@supports (animation-timeline: scroll()) {
.dsf-box {
animation: can-scroll linear;
animation-timeline: scroll(self);
}
}
@keyframes can-scroll {
from,
to {
--can-scroll: 1;
}
}
.dsf-flag {
position: sticky;
top: 0;
display: grid;
justify-items: end;
margin: 0;
padding: .5rem .75rem;
background: #141415;
font: 600 .72rem/1 monospace;
}
/* the flag inherits, so it picks which word shows */
.dsf-yes,
.dsf-no {
grid-area: 1 / 1;
padding: .3rem .6rem;
border-radius: 999px;
}
.dsf-yes {
background: #b8ff57;
color: #0c0c0d;
opacity: var(--can-scroll);
}
.dsf-no {
border: 1px solid #2a2a2d;
color: #88888f;
opacity: calc(1 - var(--can-scroll));
}
.dsf-box p:not(.dsf-flag) {
margin: 0;
padding: .6rem .9rem;
border-bottom: 1px solid #2a2a2d;
}
Scroll shadows that work everywhere
Lea Verou's technique uses four background layers on the scroller: two shadows with background-attachment: scroll, fixed to the box, and two covers with background-attachment: local, fixed to the content. The covers are solid gradients in the box's own color that fade to transparent. At the top of the content the top cover hides the top shadow. Once the content scrolls, the cover moves away with it and the shadow shows. The bottom works the same way in reverse. When nothing overflows, both covers sit on both shadows and none shows. Children with their own background hide the shadows there. It can only paint the box's background, no other cue. Safari 13 to 15.3 recognized local but it had no effect, fixed from Safari 15.4.
HTML
<div class="dsb-box" tabindex="0" role="region" aria-label="Messages">
<p>Build finished</p>
<p>New comment on Pricing</p>
<p>Invoice paid</p>
<!-- ...more rows -->
</div>
CSS
/* two shadows fixed to the box (scroll), two covers in the box's
own color fixed to the content (local) */
.dsb-box {
height: 9.5rem;
overflow-y: auto;
border: 1px solid #2a2a2d;
border-radius: 12px;
color: #f0f0f0;
font-family: system-ui, sans-serif;
background:
linear-gradient(#141415 30%, rgba(20, 20, 21, 0)) top / 100% 2.5rem no-repeat local,
linear-gradient(rgba(20, 20, 21, 0), #141415 70%) bottom / 100% 2.5rem no-repeat local,
radial-gradient(farthest-side at 50% 0, #b8ff57, rgba(184, 255, 87, 0)) top / 100% 1.1rem no-repeat scroll,
radial-gradient(farthest-side at 50% 100%, #b8ff57, rgba(184, 255, 87, 0)) bottom / 100% 1.1rem no-repeat scroll,
#141415;
}
.dsb-box p {
margin: 0;
padding: .6rem .9rem;
}
A row that fades only where there is more
The row gets container-type: scroll-state and overflow-x: auto. The fades are its own ::before and ::after, sticky flex items positioned at left: 0 and right: 0 that stick at the ends as the content scrolls. @container scroll-state(scrollable: left) sets the left fade to opacity: 1 when there is content to the left, and scrollable: right shows the right fade when there is more to the right. At the far left only the right fade shows, at the far right only the left fade shows, and in the middle both show. This contrasts with a static mask-image gradient that fades both edges all the time, whatever the scroll position and even when nothing overflows.
HTML
<div class="dsh-row" tabindex="0" role="region" aria-label="Topics">
<span class="dsh-chip">Design</span>
<span class="dsh-chip">Layout</span>
<span class="dsh-chip">Forms</span>
<span class="dsh-chip">Motion</span>
<span class="dsh-chip">Color</span>
<span class="dsh-chip">Type</span>
<span class="dsh-chip">Grid</span>
<span class="dsh-chip">Flexbox</span>
<!-- ...more chips -->
</div>
CSS
.dsh-row {
display: flex;
gap: .5rem;
max-width: 26rem;
padding: 0;
overflow-x: auto;
container-type: scroll-state;
border: 1px solid #2a2a2d;
border-radius: 12px;
background: #141415;
font-family: system-ui, sans-serif;
scrollbar-width: thin;
scrollbar-color: #2a2a2d transparent;
}
/* the fades are the row's own pseudo-elements, sticky at each end */
.dsh-row::before,
.dsh-row::after {
content: "";
position: sticky;
z-index: 1;
flex: none;
width: 2.5rem;
opacity: 0;
transition: opacity .2s;
pointer-events: none;
}
.dsh-row::before {
left: 0;
margin-right: -3rem;
background: linear-gradient(to right, #5e7e33, rgba(20, 20, 21, 0));
}
.dsh-row::after {
right: 0;
margin-left: -3rem;
background: linear-gradient(to left, #5e7e33, rgba(20, 20, 21, 0));
}
/* no padding on the row: a sticky fade stops at the padding edge,
so the chips carry the space instead */
.dsh-chip:first-of-type {
margin-left: .75rem;
}
.dsh-chip:last-of-type {
margin-right: .75rem;
}
.dsh-chip {
flex: none;
margin-block: .75rem;
padding: .45rem .8rem;
border: 1px solid #2a2a2d;
border-radius: 999px;
color: #f0f0f0;
white-space: nowrap;
}
@container scroll-state(scrollable: left) {
.dsh-row::before {
opacity: 1;
}
}
@container scroll-state(scrollable: right) {
.dsh-row::after {
opacity: 1;
}
}
Understand it
How it works and 6 properties
CSS can now detect whether an element is scrollable, and in which direction, without JavaScript. @container scroll-state(scrollable: top) matches when there is content above the visible area to scroll up to, and scrollable: bottom when there is more below. The demo is a notifications list with two cues that light only in the direction you can scroll.
The first variant is a scroll-driven animation flag that works in Firefox 159+ and Safari 26+, where scroll-state queries are not yet supported. The second variant is Lea Verou's four-layer background technique, which works everywhere except Safari 13 to 15.3. The third variant shows horizontal fades on a chips row using scrollable: left and scrollable: right.
How it works
The scroller gets container-type: scroll-state. Two @container scroll-state() queries style descendants when there is scrollable overflow in that direction: scrollable: top matches when the user can scroll up, scrollable: bottom when they can scroll down. A sticky bar at the top holds two cues, and scrollable: top lights the "More above" cue and shows a shadow under the bar. scrollable: bottom lights the "More below" cue and shows a shadow at the foot of the scroller, which is the scroller's own ::after pseudo-element. Radios switch the item count with :has() so you can see the queries respond.
The scroller is its own scroll-state container. You give the element with overflow: auto the property container-type: scroll-state, and descendants and pseudo-elements query it with @container scroll-state(scrollable: <direction>). An optional container-name lets you name the query's target. The scroller's own ::before and ::after can query it because pseudo-elements query their originating element.
scrollable: top is true once the user has scrolled down, because there is now content above the visible area. scrollable: bottom is true while there is more to scroll down to. scrollable: left and scrollable: right work the same way for horizontal scrolling. The spec also defines logical values (block-start, inline-end) and axis values (x, y). overflow: hidden does not count: scrollable matches only overflow the user can reach by scrolling. The spec says it matches when a scroll container has clipped scrollable overflow in that direction which is reachable through user-initiated scrolling.
The queries update their state at snapshot points in the event loop to avoid layout loops, so there is a one-frame lag. Transitions work: the shadows here fade by transitioning opacity, and the Chrome blog's demo fades shadows by transitioning registered custom properties that the query sets.
The three variants answer different questions and run in different browsers. Scroll-state queries ask "can the user scroll it in this direction right now," and work in Chrome and Edge 133+. The scroll-driven flag asks "is it overflowing at all" without saying where, and works in Chrome 115+, Firefox 159+ and Safari 26+. Lea Verou's background technique paints shadows at the top and bottom that appear only where there is scrollable overflow, and works everywhere except Safari 13 to 15.3.
Accessibility notes
Each scroll box has tabindex="0", role="region" and an aria-label so a keyboard user can reach it and scroll it. The radios are in one group, so Tab reaches the group and ArrowRight moves between options. The radios are sized to 1 pixel and set to opacity: 0 rather than hidden with display: none, so the radio group keeps its tab stop and arrow key navigation works.
The cues are visual extras. The list itself is the content. The shadows are decoration only, so they carry pointer-events: none and never block a click.
Under prefers-reduced-motion: reduce the cues and shadows turn off their transition declarations. The fades still appear and disappear, but instantly instead of over 0.2s.
Mistakes worth avoiding
- Container queries style descendants, not the container itself. The cues and shadows sit inside the scroller, or on its
::beforeand::after, because the scroller withcontainer-type: scroll-statecannot style itself. overflow: hiddendoes not count forscrollable. The spec saysscrollabledoes not match for a hidden container or for unreachable scrollable overflow. The scroll-driven flag does countoverflow: hidden, because the timeline spec counts any scroll container.- Putting
animation-timelinebefore theanimationshorthand in the scroll-driven variant. The shorthand resets every longhand, includinganimation-timelinetoauto, soanimation-timelinemust come after it or have higher specificity. - Cover colors in the
background-attachment: localtechnique not matching the background. The covers are solid gradients that fade to transparent, and on any other background the mismatch shows. On a dark theme the cover colors must change with it. - Using the
@media (overflow-block)feature to detect overflow. MDN says theoverflow-blockproperty does not determine whether overflow occurs, it reveals the device's handling of such overflow. It is a device query, not a page query. - Expecting
:has()or other selectors to see overflow. No selector matches whether a box overflows. Before scroll-state queries, you needed JavaScript to understand if an element was scrollable.
Ship it
4 features in 4 browsers, 6 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
scroll-state container queries | 133 | No | No | 133 |
scroll-driven animations | 115 | 159 | 26 | 115 |
background-attachment: local | 4 | 25 | 15.4 | 12 |
:has() | 105 | 121 | 15.4 | 105 |
Figures come from MDN browser-compat-data because Can I Use has no id for scroll-state queries or scroll-driven animations in the data the site checks, so those rows are unlinked. The scrollable row says Chrome and Edge 133. Can I Use's umbrella page says 144 because it bundles scrolled, which shipped in 144. The background-attachment: local row is Safari 15.4, the version where it started working again after being broken in Safari 13 to 15.3. Browsers without scroll-state queries see a notice over the main demo saying so.
What you can build with it
- Scroll shadows that show only where there is more Shadows or fades at the top and bottom of a scrolling pane, lit only in the direction the user can scroll. The third variant shows horizontal fades on a chips row.
- Back to top or scroll-to-end buttons A button that appears once there is somewhere to scroll up to, or down to. The site's Back to Top Button example shows this with a scroll-driven animation.
- Overflow hints or badges A visual flag that lights up when content overflows, like the "Overflows" / "Fits" badge in the first variant.
- Sticky headers with scroll-aware styling A sticky bar that changes appearance based on scroll position, like the shadow that appears under the cues bar once the user has scrolled.
Frequently asked questions
How do I detect if an element is scrollable with CSS?
container-type: scroll-state, then use @container scroll-state(scrollable: <direction>) to style descendants when there is scrollable overflow in that direction. scrollable: top matches when there is content above to scroll up to, scrollable: bottom when there is more below.Can CSS tell if content overflows?
@container scroll-state(scrollable: top) matches when the user can scroll up. For browsers without scroll-state queries, animation-timeline: scroll(self) with keyframes that set a custom property to 1 gives a flag that is 1 when the box overflows and 0 when it does not.How do I add scroll shadows in CSS?
@container scroll-state(scrollable: <direction>) on Chrome and Edge 133+, a scroll-driven animation flag on Chrome 115+, Firefox 159+ and Safari 26+, or Lea Verou's background-attachment: local technique that works everywhere except Safari 13 to 15.3.Does scroll-state work in Firefox or Safari?
background-attachment: local technique, which works in all three browsers except Safari 13 to 15.3.What is the difference between scrollable and the scroll-driven flag?
scrollable asks "can the user scroll it in this direction right now," so it changes as you scroll. The scroll-driven flag asks "is it overflowing at all," without saying where, and it stays the same wherever you are in the content.Does overflow: hidden count for scroll-state queries?
scrollable does not match for a hidden container or for unreachable scrollable overflow. It only matches overflow the user can reach by scrolling. The scroll-driven flag does count overflow: hidden, because its timeline is inactive only when there is no scrollable overflow at all.