Skip to content

Commit 822ceb0

Browse files
authored
fix(parallax): pin container to top-left to prevent double scrollbar (#2527)
1 parent 27bfec7 commit 822ceb0

3 files changed

Lines changed: 95 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@react-spring/parallax': patch
3+
---
4+
5+
Pin the `Parallax` container to its top-left corner (`top: 0; left: 0`). The container is `position: absolute` but previously set no offsets, so it inherited its static position. Host layouts that shift that position — such as the `place-items: center` in Vite's default `index.css` — pushed the viewport-height container below the viewport, giving the document its own scrollbar on top of the container's, hence the "double scrollbar". Closes #2255.

packages/parallax/src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,8 @@ export const Parallax = React.memo(
346346
onTouchStart={enabled ? state.stop : undefined}
347347
style={{
348348
position: 'absolute',
349+
top: 0,
350+
left: 0,
349351
width: '100%',
350352
height: '100%',
351353
...overflow,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import * as React from 'react'
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3+
import { page } from 'vitest/browser'
4+
import { render } from 'vitest-browser-react'
5+
import { Parallax, ParallaxLayer } from '@react-spring/parallax'
6+
7+
const WIDTH = 1200
8+
const HEIGHT = 600
9+
10+
// Regression for #2255 "Parallax has double/inner scrollbar".
11+
//
12+
// The Parallax container is `position: absolute`. Before the fix it set no
13+
// top/left, so it inherited its *static position*. When an ancestor offsets
14+
// that static position — e.g. the `place-items: center` in Vite's default
15+
// index.css, which the reporter kept when pasting the sandbox App "one to one"
16+
// — the viewport-height container spilled below the viewport and the document
17+
// grew its own scrollbar, on top of the container's `overflow-y: scroll` bar.
18+
// Two scrollbars. Pinning the container with top/left keeps it in the viewport.
19+
//
20+
// A viewport-filling component should never make the *document* scroll.
21+
22+
// Minimal trigger: a flex host that vertically centres its content, exactly as
23+
// Vite's default `body { display: flex; place-items: center; min-height: 100vh }`
24+
// does. Applied to the real <body> so the document is the scroll root.
25+
function applyCenteringHostCss() {
26+
const style = document.createElement('style')
27+
style.id = 'repro-2255'
28+
style.textContent = `
29+
body {
30+
margin: 0;
31+
display: flex;
32+
place-items: center;
33+
min-height: 100vh;
34+
}
35+
`
36+
document.head.appendChild(style)
37+
return style
38+
}
39+
40+
function documentVerticalOverflow() {
41+
const el = document.documentElement
42+
return el.scrollHeight - el.clientHeight
43+
}
44+
45+
describe('Parallax - double scrollbar (#2255)', () => {
46+
let hostStyle: HTMLStyleElement
47+
48+
beforeEach(async () => {
49+
await page.viewport(WIDTH, HEIGHT)
50+
hostStyle = applyCenteringHostCss()
51+
})
52+
53+
afterEach(() => {
54+
hostStyle.remove()
55+
})
56+
57+
it('does not make the document scroll', async () => {
58+
render(
59+
<div style={{ width: '100%', height: '100%' }}>
60+
<Parallax pages={3} data-testid="container">
61+
<ParallaxLayer offset={0} speed={0.5} />
62+
<ParallaxLayer offset={1} speed={0.5} />
63+
<ParallaxLayer offset={2} speed={0.5} />
64+
</Parallax>
65+
</div>
66+
)
67+
68+
// Let layout and Parallax's mount effects settle.
69+
await expect
70+
.poll(() => {
71+
const c = page.getByTestId('container').element() as HTMLElement
72+
return c.scrollHeight
73+
})
74+
.toBeGreaterThan(HEIGHT)
75+
76+
// The container is the only scroll surface; the document must not scroll.
77+
expect(documentVerticalOverflow()).toBe(0)
78+
})
79+
80+
it('control: a plain viewport-filling div does not make the document scroll', async () => {
81+
render(<div style={{ width: '100%', height: '100%' }}>plain content</div>)
82+
83+
await new Promise(r => setTimeout(r, 100))
84+
85+
// Proves the overflow is introduced by Parallax, not by the host CSS.
86+
expect(documentVerticalOverflow()).toBe(0)
87+
})
88+
})

0 commit comments

Comments
 (0)