Viewport becomes scrollable (and scrolls the whole document, mask included) when a fixed, translate-centered modal is taller than half the window — scrollable overflow ignores transforms
- Dominant language
- Rust
- Stars
- 4.1k
- Forks
- 203
- Avg merge
- 8h 58m
- Merged PRs (30d)
- 112
Description
## Summary
A modal positioned with `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%)` makes the **viewport** scrollable whenever the modal is taller than half the window. A wheel scroll then moves `viewport_scroll`, which translates the **entire** document — the fixed mask included — leaving blank space below and a dead hit-test region where the cursor disappears. Per CSS the viewport must not scroll at all: the modal paints entirely inside the window.
The scrollable distance is exactly `modal_height − window_height / 2`, which is the part of the modal that sticks out below the window **before** the translate is applied.
## Minimal reproduction
Complete runnable example (`blitz::launch_static_html`):
```rust
const HTML: &str = r#"
html, body { border:0; margin:0; padding:0; width:100vw; height:100vh; }
* { box-sizing: border-box; }
.mask {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0, 0, 0, 0.2);
}
.modal {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 300px; height: 90vh; overflow: auto; background: #fff;
}
"#;
fn main() {
blitz::launch_static_html(HTML);
}
```
Roll the wheel anywhere: the mask and body move up together by `40vh` (90vh modal, half-window offset), blank space appears below, and the cursor disappears over the blank region. Removing only the `transform` line changes the behavior to correct scrolling-by-real-overflow (the modal then genuinely sticks out).
The trigger is not specific to modals or to this geometry: any box whose border box crosses the root's padding edge **before** its transform is applied, and is pulled back inside by that transform, grants the viewport phantom scroll range equal to the pre-transform overshoot. Verified with fixed, absolute and in-flow boxes alike; the `top: 50%` + `translate(-50%, -50%)` modal is just the shape real apps hit.
## Analysis
`scroll_state` in `blitz-dom/src/scrolling.rs` reads the **layout-time** overflow:
- the viewport branch uses `root.final_layout().scrollable_overflow_rect` (and the node branch uses `final_layout().scroll_height()`, same source);
- these values are computed without any knowledge of transforms, so the modal contributes its **pre-translate** bottom edge (`window/2 + modal_height`) to the root overflow.
Blitz already computes the transform-aware bounds: `resolve_transforms` in `blitz-dom/src/resolve.rs` applies `Affine::translate(location) * node.transform` to child contributions and stores the result in `ElementData::scrollable_overflow()` (used by paint and hit-testing). Scrolling is the only consumer still reading the pre-transform value. Measured on a 100×100 viewport with a 40×64 modal:
| source | translated | untranslated |
|---|---|---|
| layout-time `scrollable_overflow_rect.bottom` (used by `scroll_state`) | **114 (wrong)** | 114 |
| transform-aware `ElementData::scrollable_overflow()` | **100 (correct)** | 114 |
css-overflow-3 §3.3 requires the scrollable overflow area to be calculated **accounting for transforms** — each contributing box enters with its transform applied. The modal's border box therefore lies entirely within the window, and it should add nothing to the root's scrollable overflow. The layout-time value used by `scroll_state` instead reflects the modal's position before `translate(-50%, -50%)`, granting the viewport `modal_height − window_height/2` of scroll range that corresponds to nothing on screen.
Contributor guide
Research direction
Reproduce the issue with the provided blitz::launch_static_html example, then read blitz-dom/src/scrolling.rs and resolve.rs to trace the two overflow calculations. Compare the viewport and node scroll-state inputs with ElementData::scrollable_overflow() for transformed and untransformed boxes. Done means the translated modal adds no phantom viewport scroll range, the mask remains fixed during wheel input, and genuine overflow still scrolls correctly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- css, rust
- Domain
- frontend, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100