diff --git a/src/App.css b/src/App.css index 6c7e63fc..a7eca11b 100644 --- a/src/App.css +++ b/src/App.css @@ -33,6 +33,7 @@ border-radius: var(--radius-lg); padding: 0.75rem 1rem; overflow-x: auto; + scrollbar-width: thin; margin: 0.6em 0; } .md pre code { @@ -1412,6 +1413,8 @@ a.global-player-title:hover { padding: 0 0.5rem 0.5rem; max-height: 40vh; overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; } .global-player-track > button { display: flex; @@ -2893,6 +2896,8 @@ body.has-player .chat-fab { trigger's viewport rect, flipping above it when there isn't room below */ min-width: 180px; overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; background: var(--color-surface); border: var(--border-width) solid var(--color-border-subtle); border-radius: var(--radius-xl); @@ -3936,6 +3941,8 @@ body.has-player .chat-fab { flex: 1 1 auto; min-height: 0; overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; display: flex; flex-direction: column; gap: 0.75rem; diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index cdac3352..d6cf79fa 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { createPortal } from "react-dom"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { useScrollLock } from "../hooks/useScrollLock"; interface ConfirmModalProps { message: string; @@ -14,6 +15,7 @@ export function ConfirmModal( { message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps, ) { const label = confirmLabel ?? t`Delete`; + useScrollLock(); useEffect(() => { const onKey = (e: KeyboardEvent) => { diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index d2a5b618..b6c57b3c 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { t } from "@lingui/core/macro"; +import { useScrollLock } from "../hooks/useScrollLock"; interface ModalProps { title: string; @@ -25,12 +26,7 @@ export function Modal( onClose(); }, [onBeforeClose, onClose]); - useEffect(() => { - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = ""; - }; - }, []); + useScrollLock(); useEffect(() => { const handler = (e: KeyboardEvent) => { diff --git a/src/hooks/useScrollLock.ts b/src/hooks/useScrollLock.ts new file mode 100644 index 00000000..2d654510 --- /dev/null +++ b/src/hooks/useScrollLock.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; + +let locks = 0; + +/** + * Freezes the page's scroll while the calling component is mounted — for + * modals, whose backdrop shouldn't let the feed slide underneath. + * + * Hiding the viewport's overflow drops its scrollbar, which would widen the + * page and shift everything behind the backdrop. If a bar is showing, its + * gutter is held for the duration of the lock instead, so the layout stays + * put; no gutter is reserved the rest of the time. Locks are counted so a + * confirm stacked on a modal doesn't release the page when it closes first. + */ +export function useScrollLock() { + useEffect(() => { + const root = document.documentElement; + if (locks++ === 0) { + if (window.innerWidth > root.clientWidth) { + root.style.scrollbarGutter = "stable"; + } + root.style.overflowY = "hidden"; + } + return () => { + if (--locks === 0) { + root.style.overflowY = ""; + root.style.scrollbarGutter = ""; + } + }; + }, []); +} diff --git a/src/index.css b/src/index.css index 6168df7b..53209d52 100644 --- a/src/index.css +++ b/src/index.css @@ -4,11 +4,17 @@ box-sizing: border-box; } +html { + overflow-x: clip; + /* Inherited by every scroller: a theme-tinted thumb on a clear track + instead of the stock grey bar. */ + scrollbar-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent) + transparent; +} + body { margin: 0; min-height: 100vh; - overflow-x: clip; - scrollbar-gutter: stable; background-color: var(--color-bg); color: var(--color-text); -webkit-font-smoothing: antialiased;