# :seen, :in-view, :stuck and :overflowing — state selectors CSS doesn't have

> Reveal elements on scroll with :seen and :in-view, style pinned sticky headers with :stuck, and detect truncated text with :overflowing. No custom JavaScript.

Source: https://csspp.dev/docs/state-selectors/


> **More state selectors:** [scroll and navigation](/docs/scroll-navigation/) (`:current`, `:scrolled`, `:scrolling-down`, `:scroll-end`, scrollspy) and [media, layout and forms](/docs/media-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}

`:seen` turns on the first time an element enters the viewport and stays on. It is the CSS-only replacement for scroll-reveal libraries:

```css
.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:

```css
.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:

```css
.hero:seen(300px) { background-image: url(/img/hero-large.jpg); }
```

## :in-view — on screen right now {#in-view}

`: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:

```css
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}

`: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:

```css
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}

`: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:

```css
.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:

```css
.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:

- `:seen` and `:in-view` use one `IntersectionObserver` per threshold.
- `:stuck` measures the element's offset from its normal position as you scroll.
- `:overflowing` uses a `ResizeObserver`, plus re-checks when content changes and when fonts load.
- A single `MutationObserver` picks 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.
