dequelabs / dequelabs/axe-core
isOffscreen incorrectly handles scrolled elements, especially with position: absolute and horizontal scroll
- Dominant language
- JavaScript
- Stars
- 7.5k
- Forks
- 933
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 29
Description
## Problem
`dom.isOffscreen` is used to determine if an element is visually hidden by being placed outside the viewport. It has a helper function `noParentScrolled` that attempts to distinguish between elements that are intentionally positioned offscreen (e.g. `left: -9999px`) and elements that are simply scrolled out of view inside a scroll container. There are several issues with how this works today.
## Issue 1: `position: absolute` elements always treated as offscreen when above the viewport
In the "above viewport" check:
```js
if (
coords.bottom <= 0 &&
(noParentScrolled(domNode, coords.bottom) || styl.position === 'absolute')
) {
return true;
}
```
The `|| styl.position === 'absolute'` condition bypasses the scroll-parent check entirely. This assumes that an absolute element above the viewport must be intentionally hidden. But an absolute element is positioned relative to its nearest positioned ancestor — if that ancestor is inside a scroll container, the absolute element scrolls with it. For example:
```html
```
When the scroll container is scrolled down, the absolute element moves above the viewport. `isOffscreen` incorrectly reports it as offscreen (hidden), which causes `isVisible` / `isVisibleOnScreen` to treat it as invisible.
## Issue 2: No horizontal scroll consideration
`noParentScrolled` only checks `scrollTop` (vertical scrolling). The horizontal offscreen checks don't use `noParentScrolled` at all:
```js
if (dir === 'ltr') {
return coords.right <= 0;
}
```
If an element is inside a horizontally scrollable container and has been scrolled to the left so that `coords.right <= 0`, `isOffscreen` reports it as offscreen with no scroll-container check to guard against it.
## Issue 3: `noParentScrolled` doesn't account for CSS positioning contexts
The function walks the entire ancestor tree up to ``, accumulating `scrollTop`. It doesn't consider how positioning contexts affect which scroll containers are relevant:
- For `static`/`relative` elements, any ancestor scroll container can scroll the element.
- For `absolute` elements, only scroll containers at or above the containing block (nearest positioned ancestor) are relevant.
- For `fixed` elements, no ancestor scroll containers are relevant — fixed elements are positioned relative to the viewport.
Currently, `noParentScrolled` doesn't distinguish these cases. This can cause false negatives for `fixed` elements: if the page is scrolled down, `noParentScrolled` finds a `scrollTop` on an ancestor and returns `false` ("a parent is scrolled"), preventing a truly offscreen `fixed` element from being detected.
## Impact
`isOffscreen` is used by both `isVisible` and `isVisibleOnScreen`. False positives (reporting scrolled content as offscreen) cause those elements to be treated as invisible, which can lead to:
- Rules skipping elements that are actually visible and interactive
- Color contrast checks not running on content that is visible when scrolled into view
- `target-size` and other checks not flagging visible elements
## Suggested approach
Rather than accumulating `scrollTop`/`scrollLeft` up the tree, check whether the element's layout position (ignoring scroll) falls within the scrollable range of its ancestor scroll containers. If it does, the element is "in the page" even if temporarily scrolled out of view.
The fix should:
1. Remove the `position: absolute` shortcut
2. Add horizontal scroll-container awareness
3. Account for CSS positioning contexts when determining which scroll containers are relevant
4. Handle `position: fixed` elements separately (they are unaffected by ancestor scroll)
Contributor guide
Research direction
Start at the dom.isOffscreen entry point and its noParentScrolled helper, then trace the existing visibility checks for vertical and horizontal coordinates. Verify behavior for absolute, static/relative, and fixed elements in scrolled containers; done means scrolled content is not treated as hidden while intentionally offscreen and fixed elements remain independent of ancestor scrolling.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- accessibility
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100