State selectors
More state selectors: scroll and navigation (
:current,:scrolled,:scrolling-down,:scroll-end, scrollspy) and media, layout and forms (:loaded,:broken,:wrapped,:filled,:dirty,:offline,:idle).
These pseudo-classes describe things the browser knows but native CSS can't test: whether an element is on screen, has been seen, is pinned by position: sticky, or overflows its box. Use them like any other pseudo-class, including inside :has() and :not().
| Selector | Matches when | Native CSS in 2026 |
|---|---|---|
:in-view |
Any part of the element is in the viewport | No selector. Scroll-triggered animations exist in Chromium only. |
:in-view(50%) |
At least 50% of the element is visible | No |
:seen |
The element has been in view at least once | No |
:seen(30%) |
At least 30% of it has been visible at least once | No |
:seen(300px), :in-view(300px) |
Within 300px of the viewport (triggers early) | No |
:stuck |
A position: sticky element is currently pinned |
scroll-state(stuck) container queries, Chromium only |
:overflowing |
The content is larger than the box: truncated text or an overflowing container | No |
:seen — reveal on scroll, once#
:seen turns on the first time an element enters the viewport and stays on. It is the CSS-only replacement for scroll-reveal libraries:
.card { transition: opacity .6s ease, translate .6s ease; }
.card:not(:seen) { opacity: 0; translate: 0 2rem; }Write the hidden state as :not(:seen). Browsers that never run css-pp don't understand that selector and skip the rule, so the content stays visible.
Add a threshold to wait until enough of the element is visible:
.feature:not(:seen(40%)) { opacity: 0; }Or give a pixel margin to trigger before the element is visible. This is ideal for lazy-loading background images:
.hero:seen(300px) { background-image: url(/img/hero-large.jpg); }:in-view — on screen right now#
:in-view toggles every time an element enters or leaves the viewport. Use it to pause animations offscreen, highlight the current section, or change the header:
video:not(:in-view) { visibility: hidden; }
.toc a { opacity: .6; }
section:in-view(60%) { --active: 1; }
/* make the header solid once the hero is gone */
body:has(.hero:not(:in-view)) header { background: #fff; }:stuck — sticky elements that are pinned#
:stuck matches a position: sticky element while its sticky offset is applied, meaning it is pinned rather than in its normal place. It works for top and bottom sticky elements, in the page or inside a scrolling container:
header { position: sticky; top: 0; transition: box-shadow .2s; }
header:stuck { box-shadow: 0 2px 12px rgb(0 0 0 / .15); }
.table th { position: sticky; top: 0; }
.table th:stuck { background: #fff; }A header at the very top of the page is not stuck until the page scrolls, which matches what you see on screen.
:overflowing — truncated text and overflowing boxes#
:overflowing is on when an element's content is bigger than its box, horizontally or vertically. Use it for text-overflow: ellipsis, line-clamp and overflow: auto areas:
.title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* show the full title on hover, only when it is actually cut off */
.title:overflowing:hover::after { content: attr(data-full); }
/* reveal a "Read more" link only for long excerpts */
.excerpt { -webkit-line-clamp: 3; display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
.post .more { display: none; }
.post:has(.excerpt:overflowing) .more { display: inline; }It updates when the element is resized, when its content changes and when web fonts finish loading.
Avoid feedback loops. Don't change the overflowing box's own size or content in a rule that depends on
:overflowing. If that rule stops the overflow, the state flips back and forth. Style a sibling, a child or a pseudo-element instead, as in the examples above.
Using them with :has()#
Each state selector compiles to an attribute selector, so it works anywhere a selector is allowed:
.carousel:has(.slide:in-view:last-child) .next { opacity: .3; }
.form:has(textarea:overflowing) .expand { display: block; }
nav:has(+ main .hero:not(:in-view)) { backdrop-filter: blur(8px); }How it works#
css-pp rewrites each state selector to an attribute selector. .card:seen becomes .card[data-css-seen], which has the same specificity as the pseudo-class. It then watches only the elements that can match:
:seenand:in-viewuse oneIntersectionObserverper threshold.:stuckmeasures the element's offset from its normal position as you scroll.:overflowinguses aResizeObserver, plus re-checks when content changes and when fonts load.- A single
MutationObserverpicks up elements added later, such as content loaded by AJAX.
The attributes appear in your DOM inspector as data-css-seen, data-css-in-view, data-css-stuck and data-css-overflowing. You can also use them in your own CSS and JavaScript.