diff --git a/.changeset/parallax-pin-container.md b/.changeset/parallax-pin-container.md new file mode 100644 index 0000000000..fe20450eb4 --- /dev/null +++ b/.changeset/parallax-pin-container.md @@ -0,0 +1,5 @@ +--- +'@react-spring/parallax': patch +--- + +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. diff --git a/packages/parallax/src/index.tsx b/packages/parallax/src/index.tsx index ee35d912fa..75c2fd5f6d 100644 --- a/packages/parallax/src/index.tsx +++ b/packages/parallax/src/index.tsx @@ -346,6 +346,8 @@ export const Parallax = React.memo( onTouchStart={enabled ? state.stop : undefined} style={{ position: 'absolute', + top: 0, + left: 0, width: '100%', height: '100%', ...overflow, diff --git a/tests/e2e/parallax-scrollbar.spec.tsx b/tests/e2e/parallax-scrollbar.spec.tsx new file mode 100644 index 0000000000..d22ebba7d7 --- /dev/null +++ b/tests/e2e/parallax-scrollbar.spec.tsx @@ -0,0 +1,88 @@ +import * as React from 'react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { page } from 'vitest/browser' +import { render } from 'vitest-browser-react' +import { Parallax, ParallaxLayer } from '@react-spring/parallax' + +const WIDTH = 1200 +const HEIGHT = 600 + +// Regression for #2255 "Parallax has double/inner scrollbar". +// +// The Parallax container is `position: absolute`. Before the fix it set no +// top/left, so it inherited its *static position*. When an ancestor offsets +// that static position — e.g. the `place-items: center` in Vite's default +// index.css, which the reporter kept when pasting the sandbox App "one to one" +// — the viewport-height container spilled below the viewport and the document +// grew its own scrollbar, on top of the container's `overflow-y: scroll` bar. +// Two scrollbars. Pinning the container with top/left keeps it in the viewport. +// +// A viewport-filling component should never make the *document* scroll. + +// Minimal trigger: a flex host that vertically centres its content, exactly as +// Vite's default `body { display: flex; place-items: center; min-height: 100vh }` +// does. Applied to the real so the document is the scroll root. +function applyCenteringHostCss() { + const style = document.createElement('style') + style.id = 'repro-2255' + style.textContent = ` + body { + margin: 0; + display: flex; + place-items: center; + min-height: 100vh; + } + ` + document.head.appendChild(style) + return style +} + +function documentVerticalOverflow() { + const el = document.documentElement + return el.scrollHeight - el.clientHeight +} + +describe('Parallax - double scrollbar (#2255)', () => { + let hostStyle: HTMLStyleElement + + beforeEach(async () => { + await page.viewport(WIDTH, HEIGHT) + hostStyle = applyCenteringHostCss() + }) + + afterEach(() => { + hostStyle.remove() + }) + + it('does not make the document scroll', async () => { + render( +
+ + + + + +
+ ) + + // Let layout and Parallax's mount effects settle. + await expect + .poll(() => { + const c = page.getByTestId('container').element() as HTMLElement + return c.scrollHeight + }) + .toBeGreaterThan(HEIGHT) + + // The container is the only scroll surface; the document must not scroll. + expect(documentVerticalOverflow()).toBe(0) + }) + + it('control: a plain viewport-filling div does not make the document scroll', async () => { + render(
plain content
) + + await new Promise(r => setTimeout(r, 100)) + + // Proves the overflow is introduced by Parallax, not by the host CSS. + expect(documentVerticalOverflow()).toBe(0) + }) +})