Skip to content

feat(core): BottomSheet locks page scroll by CSS keyed off its modal dialog - #7719

Closed
imdreamrunner wants to merge 2 commits into
spec/bottom-sheet-per-sheet-dialogfrom
feat/bottom-sheet-css-scroll-lock
Closed

imdreamrunner wants to merge 2 commits into
spec/bottom-sheet-per-sheet-dialogfrom
feat/bottom-sheet-css-scroll-lock

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on the spec PR #7744, which records the decision this PR implements (component:BottomSheet FR5 / DEC-2). This PR carries code, tests, builder docs, and the Changeset only; it touches no knowledge record. The per-step switcher dialogs stack on this PR and can be dropped without affecting it.

Primary intent

Lock page scroll for a modal BottomSheet with CSS keyed off the browser's own modal-dialog state instead of pinning <body> from script. One stylesheet, installed once per document, freezes the page's scroller while any sheet dialog is modal — a standalone sheet, a BottomSheetSwitcher flow through its handoffs, a sheet beneath a Dialog opened from it, and however many sheet dialogs a future stacking model keeps open — with no counter, no saved scroll offset, and no inline styles on <body>. It freezes the root, or <body> on a page whose <body> owns the viewport scroll, so the page keeps its offset either way. It is independent of where the switcher keeps its dialog: it applies to the standalone sheet and to the switcher's existing shared dialog alike.

The other templates do not fit: nothing is broken (bug-fix), no capability is added (new-feature), pixels are unchanged at rest (visual-update).

User or maintainer impact

Consumers see the same sheets: page scroll locked while a modal sheet is open, released after its exit motion. Two observable details change (see Behaviour deltas): window.scrollY keeps reading the page's offset while a sheet is open, and a page that scrolls a nested element rather than the viewport is no longer pinned by the sheet. Maintainers lose useScrollLock from both sheet hosts and gain a ~120-line module (sheetScrollLock.ts) whose only state is one <style> element.

Problem and solution fit

Why change the lock? showModal() makes the page inert but does not stop it scrolling; today the sheet answers that by pinning <body> (position: fixed; top: -scrollY), keeping a lock counter across overlays, measuring the scrollbar gutter, and calling scrollTo on release. Why does that matter? Each of those is state to keep balanced: a second open sheet, a sheet beneath a Dialog, a flow that re-presents a dialog all have to count correctly, and the body pin makes scrollY read 0 for anything else on the page. Why does that matter? Every design that keeps more than one sheet dialog open multiplies the bookkeeping, while the browser already knows exactly when a sheet dialog is modal — dialog:modal — and :root:has(dialog[data-sheet-dialog]:modal) { overflow: hidden } is true in precisely those moments.

Every delta maps to that:

Delta Why
sheetScrollLock.ts: the rules, the once-per-document installer, and prepareSheetScrollLock(dialog) called by each host before showModal() The lock, and the only moment a host can measure the scrollbar
data-sheet-dialog on every sheet dialog (standalone and switcher) Scopes the rule to sheets; other components keep their own scroll handling
data-sheet-locks-body set on the dialog when, at present time, the root's computed overflow is visible and <body>'s is not; the lock rule then freezes <body> instead of the root CSS applies <body>'s overflow to the viewport only while the root's is visible. Freezing the root on such a page (html, body { height: 100% } + body { overflow-x: hidden }, or body { height: 100vh; overflow-y: auto }) stops that propagation, makes <body> a viewport-tall scroller, and clamps scrollY 300 → 0 (measured on the previous head); freezing <body> propagates hidden to the viewport and keeps 300
data-sheet-holds-gutter set on the dialog only when window.innerWidth > documentElement.clientWidth at present time; a rule applies scrollbar-gutter: stable on the root only then A classic scrollbar's gutter is not covered by the ::backdrop; reserving one on a page that had no scrollbar opens an undimmed 15px strip and shifts the sheet 7.5px (measured). Same condition as useScrollLock's gutter hold. The root is the right element in both lock modes: scrollbar-gutter on the root reaches the viewport whichever element's overflow it uses (measured: scrollbar-gutter on <body> does not)
Where CSS.supports('scrollbar-gutter: stable') is false (Safari 17.0–18.1, Tier 2), the shared holdScrollbarGutter helper pads <body> by the width it actually grew, held by the first modal sheet dialog that needs it and released when the last one's open attribute goes (a MutationObserver, so release runs as a microtask before the next paint) spec:AST-013 FR7: equivalent behaviour on the supported floor, reusing the exact fallback useScrollLock ships rather than a new mechanism. prepareSheetScrollLock(dialog) now returns the settle call the host makes right after showModal(), which is when the helper measures growth
acquireSheetScrollLock(dialog) returns the release; each host calls it in the effect that keeps its dialog a presented modal sheet and returns the release as that effect's cleanup (shouldPresent && hasScrim for the standalone sheet, isModal for the switcher) Membership in the padding hold is paired with the state that needs it, so a close, an unmount while presented, and a StrictMode effect replay all release through one path; no unmount-only effect, no per-dialog MutationObserver (removed, so nothing is left connected)
Every presented modal sheet dialog joins the document's hold once it exists, whatever it measures A second sheet dialog presented while the first holds the lock measures no scrollbar (the lock hid it: clientWidth === innerWidth); previously it returned early and never joined, so closing the first released the padding under the second — a 15px shift mid-flow
Release is deferred by one microtask when the member count hits zero A StrictMode replay runs cleanup and effect again in the same commit while the dialog is already open, so nothing could re-measure the growth; deferring the release keeps the hold and its measured padding across the replay. A real last-leave still releases before the next paint
useScrollLock removed from StandaloneBottomSheet and BottomSheetSwitcher Replaced outright; Dialog, Drawer, Lightbox keep the hook

Removable tagalongs: none. The .doc.mjs edits only describe the mechanism that changed.

Authority

  • component:BottomSheet FR5 and DEC-2 (spec PR spec(core): each BottomSheetSwitcher step owns its native dialog #7744) — FR5 states the effects (no page scroll while a sheet dialog is modal; the sheet's lock sets no inline positioning or overflow on <body>; offset unchanged at release; gutter held only when a classic scrollbar takes space, as a <body> padding where scrollbar-gutter is unsupported, released with the lock); DEC-2 binds the lock to the dialog's modal state and leaves the mechanism to the implementation. component:BottomSheetSwitcher FR5 and PR1 reference it.
  • spec:AST-013 FR10 — browser-owned behaviour needs real-browser evidence: Chromium and WebKit evidence is attached; iOS Safari touch scrolling is not verified here (see Open risk).
  • architecture:layer-runtime — native showModal() hosting unchanged.
  • spec:AST-002 / architecture:public-component-api — props, defaults, exports unchanged.

Behaviour deltas

Area main This PR
Lock mechanism inline position: fixed; top: -scrollY; overflow: hidden on <body>, lock counter, scrollTo on release :root { overflow: hidden } while a sheet dialog is modal; <body> untouched
window.scrollY while a sheet is open 0 (restored by script on close) the page's offset, unchanged (measured 300 → 300 → 300)
Page that scrolls a nested element, not the viewport pinned not pinned; the sheet's own content still contains its scroll
Scrollbar gutter, page with a classic scrollbar held via scrollbar-gutter: stable on root; padding fallback where unsupported held, same way, only while the sheet is modal; same padding fallback where unsupported
Scrollbar gutter, page without a scrollbar none none (the dialog is not marked, so no gutter)
Page whose <body> owns the viewport scroll (html, body { height: 100% } + body { overflow-x: hidden }; body { height: 100vh; overflow-y: auto }) pinned, offset kept <body> frozen (its hidden propagates to the viewport), offset kept — the previous head of this PR clamped to 0
Lock through a switcher handoff / under a Dialog from a sheet / through the exit motion held held (the dialog is :modal throughout)
Sheet unmounted while presented, engine without scrollbar-gutter padding released by the hook's effect cleanup padding released by the host's paired effect cleanup
Second sheet presented while the first holds, engine without scrollbar-gutter hook counter: padding until the last releases joined whatever it measures: padding until the last presented modal sheet leaves (previous head: released with the first)
<StrictMode>, sheet mounted open, engine without scrollbar-gutter padding held padding held across the replay (previous head: lost)
iOS Safari touch pinned overflow: hidden on the root — unverified on hardware

Test edits

Existing suites ran unedited against the new code first: 2 failures, both assertions on the removed body pin (document.body.style.position === 'fixed') in "keeps a standalone modal sheet presented through its exit animation" and "dismisses the flow from the one shared scrim". Both now assert the lock's precondition from the DOM (isScrollLockEngaged(): an open dialog[data-sheet-dialog] the stubs recorded as modal), since jsdom evaluates neither :modal nor :has(); the behaviour asserted (lock held through the exit, released after it) is unchanged. Two more assertions in the switcher suite that checked the body was not pinned now check the lock is not engaged.

New: sheetScrollLock.test.ts (8: rule text — root rule, body rule, gutter rule on the root only; once-per-document install; iframe copy; the gutter mark set and cleared by the measured viewport; the body-lock mark set for both page shapes and cleared for a self-scrolling root; the padding fallback engages where scrollbar-gutter is unsupported; a second dialog that measures no scrollbar (the first lock hid it: clientWidth 1280) joins the hold anyway; the first's leave keeps the padding, the last's releases it; no padding where the property is supported or no scrollbar takes space; a leave-then-join in one task (a StrictMode replay) keeps the hold, and a presented dialog removed from the document releases through its paired cleanup), BottomSheet.test.tsx "native modal host" (8: attribute + stylesheet installed once before presenting with <body> untouched; both hosts give up the padding fallback when unmounted while presented; sheet B opened while sheet A holds the padding — A closes through its exit, padding stays 15px until B closes — fails on the previous head (padding released with A); <StrictMode> with a sheet mounted open: padding held after the replay, released on close and on unmount — fails on the previous head (padding lost to the replay); gutter held only when a classic scrollbar took space at present time; lock held under a Core Dialog and released only with the sheet; native cancel by purpose; backdrop click by purpose), BottomSheetSwitcher.test.tsx (1: the flow's one modal dialog locks by CSS across handoffs, no body pin, released after the last exit), and __tests__/BottomSheetScrollLock.a11y.browser.spec.ts (4, Chromium + WebKit: the lock through a handoff, under a probe showModal() dialog, released after the exit with scrollY unchanged and <body> untouched, no chaining from the sheet's content; the offset kept on both body-owned-scroller page shapes, <body> frozen and the root left visible, <body> never becoming its own scroller — both fail on the previous head with scrollY 300 → 0 and pass now; a non-modal flow locks nothing). Against the main build the first browser test fails as expected (root overflow stays visible).

Change and evidence

Files: sheetScrollLock.ts (new), BottomSheet.tsx, BottomSheetSwitcher.tsx, the two .doc.mjs, the three test files, the browser spec, a [feat] Changeset (patch: no public API delta). No knowledge record is touched; the record this implements is #7744.

API audit. Core built on origin/main and on this branch: exports map identical; BottomSheet/index.d.ts and the root index.d.ts byte-identical; BottomSheet.d.ts and BottomSheetSwitcher.d.ts differ only in doc comments; sheetScrollLock.d.ts is new and unreachable from any public entry.

Browser (Chromium 149 and WebKit under Playwright 1.61, static Storybook of main and of this branch).

  • 390×844 touch, hug-content / capped / no-scrim / standalone showcase: page at scrollY 300, flow opened from an off-screen opener; wheel 400 over the backdrop → 300; real finger drag 300px (Chromium, CDP) → 300; wheel 3000 and drag 400px inside the sheet content → 300 (no chaining); mid-handoff → 300; after close → 300 with root overflow back to visible; <body> carries no inline style at any point; viewport width 390 → 390. main: body pinned, scrollY reads 0, restored to 300 by script. Both engines.
  • 1280×800 with classic scrollbars (Chromium, --hide-scrollbars removed), six page shapes (root scroller; html, body { height: 100% } + body { overflow-x: hidden }; body { height: 100vh; overflow-y: auto } — each non-scrolling and scrolling at offset 300): non-scrolling — layout viewport 1280 → 1280, no undimmed strip, no padding, <body> untouched; scrolling (15px scrollbar before) — <body> width 1265 → 1265 (gutter held on the root in both lock modes), scrollY 300 → 300 through wheel and after close, <body> untouched. With CSS.supports('scrollbar-gutter') forced false and the property neutralised (html { scrollbar-gutter: auto !important }, simulating Safari 17.0–18.1): <body> padded 15px inline while the sheet is open and '' after close, on every scrolling shape; nothing on non-scrolling ones. An unconditional scrollbar-gutter: stable on a non-scrolling page shows the strip (255 vs 127) and the sheet at 632.5. WebKit under Playwright has overlay scrollbars (0px) and so cannot show the gutter case.
  • Scrim, focus, dismissal traces identical to main in both engines; settled screenshots pixel-identical (36 of 36 pairs); no console errors or warnings.

iOS check (owner-owed)

spec:AST-013 FR10 asks for the named browser. iOS Safari's handling of a frozen scroller (overflow: hidden on the root, or on <body> when it owns the viewport scroll) against a finger could not be measured here: desktop WebKit under Playwright cannot be driven by native touch and is not iOS. component:BottomSheet FR5 (#7744) names that device check as owed. Steps, on an iPhone running the oldest supported iOS Safari and the current one, against this branch's static Storybook served over the LAN:

  1. Open iframe.html?id=core-bottomsheetswitcher--hug-content&viewMode=story; dismiss the flow (tap the scrim). In the Web Inspector console run document.body.appendChild(Object.assign(document.createElement('div'), {style: 'height:3000px'})); scrollTo(0, 300).
  2. Tap Set up notifications. Drag a finger up and down over the dimmed page, then over the sheet's content past its end. Expected: the page does not move, scrollY stays 300, the sheet's content scrolls and does not chain. Rubber-banding at the viewport edge is acceptable; a page that scrolls is a failure.
  3. Tap Continue (two dialogs open mid-handoff) and repeat the drag. Then tap the scrim to close. Expected: scrollY still 300 after the exit motion, and the page scrolls again.
  4. Repeat 1–3 after adding html, body { height: 100% } body { overflow-x: hidden } to the page (step 1 console: document.head.appendChild(Object.assign(document.createElement('style'), {textContent: 'html,body{height:100%}body{overflow-x:hidden}'}))).
  5. Repeat step 2 on iframe.html?id=core-bottomsheet--showcase&viewMode=story (a standalone sheet) and with the phone rotated.

If any step fails, this PR is not merged: nothing else depends on it, and the sheets keep useScrollLock.

Scope

  • This PR has one primary intent.
  • Independently removable deltas were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

pnpm exec vitest run packages/core/src/{BottomSheet,Dialog,AlertDialog,Selector,DropdownMenu,ContextMenu,DateInput,DateTimeInput,TimeInput,Tokenizer,Layer,Layout,CommandPalette,MoreMenu,Popover,Lightbox,MobileNav}/ packages/lab/src/MobileTokenizer/
  main:   Test Files 86 passed, Tests 2452 passed (+1 suite, DropdownMenuLazySheet, hit its 10s import hook timeout under the full parallel run on this host, on main and on every branch alike; it passes alone: 2 passed)
  branch: Test Files 87 passed, Tests 2469 passed   (+17 new, 1 new file; same unrelated hook timeout)
  existing BottomSheet/Dialog/Layer suites unedited against the new code: 2 failed (both the removed body pin), 433 passed
pnpm -F @astryxdesign/core typecheck                      ✓
ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/BottomSheet/   0 errors (1 pre-existing warning in an untouched spec)
pnpm check:sync · check:knowledge · check:package-boundaries · check:changesets   ✓
node scripts/sync-exports.js --check                      ✓
git diff --check                                          ✓
pnpm exec vitest run --project node scripts/build-css.test.mjs   11 passed (Core rebuilt first)
node scripts/release/changeset-coverage.mjs pr --base origin/main --head HEAD --released v0.7.1   ✓ (newest tag v0.7.1)
pnpm storybook:build                                      ✓ (main and branch)
pnpm exec playwright test packages/core/src/BottomSheet/__tests__/ --project chromium   42 passed (38 existing + 4 new)
pnpm exec playwright test packages/core/src/BottomSheet/__tests__/ --project webkit     34 passed (30 existing + 4 new)
  the two body-owned-scroller tests against the previous head's build: 2 failed (scrollY 300 → 0), as intended

@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 20, 2026 11:44pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 20, 2026
@astracat-bot

astracat-bot Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 21, 2026, 12:10 AM UTC

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview.

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | View full report

@imdreamrunner
imdreamrunner force-pushed the feat/bottom-sheet-css-scroll-lock branch from 1a42428 to a84ca11 Compare October 20, 2026 17:38
@imdreamrunner
imdreamrunner changed the base branch from main to spec/bottom-sheet-per-sheet-dialog October 20, 2026 17:38

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on a maintainer decision. This scroll lock rests on component:BottomSheet FR5 and DEC-2, which exist only in the draft spec PR #7744, so a maintainer needs to approve or amend that record, or name other governing spec for a document-lifetime CSS scroll lock, before the change can be judged. Whatever that decision is, three things need work:

  • On a page whose <body> has a non-visible overflow and a constrained height (for example html, body { height: 100% } plus body { overflow-x: hidden }), opening a modal sheet jumps the page to its top and the scroll offset is lost on close, because overflow: hidden on the root stops <body>'s overflow from propagating to the viewport; locking <body> in that case keeps the offset.
  • Desktop Safari 17.0–18.1 (Tier 2) has no scrollbar-gutter, so the gutter hold needs the shared hook's padding fallback or a documented reduced fallback (spec:AST-013 FR7).
  • spec:AST-013 FR10 needs real-device iOS Safari evidence for the new lock, which Playwright WebKit cannot stand in for.

[Automated review]

/** The lock, as CSS text. */
export function sheetScrollLockCss(): string {
return (
`${SHEET_SCROLL_LOCK_SELECTOR}{overflow:hidden}` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting overflow: hidden on the root changes overflow propagation. CSS applies <body>'s overflow to the viewport only while the root's is visible, so on a page with html, body { height: 100% } and body { overflow-x: hidden } (or body { height: 100vh; overflow-y: auto }), <body> becomes a viewport-tall scroller when a sheet opens. scrollY then clamps to 0 and the page stays at the top after close; in Chromium it went from 300 to 0, where the base keeps 300. When, at present time, the root's computed overflow is visible and <body>'s is not, locking <body> instead keeps the offset.

export function sheetScrollLockCss(): string {
return (
`${SHEET_SCROLL_LOCK_SELECTOR}{overflow:hidden}` +
`${SHEET_SCROLLBAR_GUTTER_SELECTOR}{scrollbar-gutter:stable}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollbar-gutter reached Safari only in 18.2 (see hooks/scrollbarGutter.ts), and the Browser Support guide's Tier 2 includes Safari 17+. On those versions the removed useScrollLock path padded <body> by its measured growth; this rule has no fallback, so the page reflows by the scrollbar's width there. spec:AST-013 FR7 asks for equivalent behaviour or a documented reduced fallback.

* modal, their once-per-document installer, and the per-present scrollbar
* gutter measurement
* @position Core tests for sheetScrollLock.ts; the engines' reading of the
* rules is BottomSheetNativeModal.a11y.browser.spec.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BottomSheetNativeModal.a11y.browser.spec.ts does not exist at this head; the engine evidence here is __tests__/BottomSheetScrollLock.a11y.browser.spec.ts.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on a maintainer decision: this scroll lock rests on component:BottomSheet FR5 and DEC-2, which exist only in the draft spec PR #7744, so a maintainer needs to approve or amend that record (the CSS lock keyed off :modal, the scrollY and nested-scroller changes, the document-lifetime stylesheet, and whether FR5's "no inline styles on <body>" allows the Safari 17.0-18.1 padding fallback) before this can land. The offset jump on body-scrolled pages from the earlier revision is fixed. Independently of the spec decision, two things are still needed:

  • On engines without scrollbar-gutter, a sheet that unmounts while presented never releases the <body> padding, because the hold is left only when the dialog's open attribute changes (sheetScrollLock.ts:188-192); release it on unmount and add that case to sheetScrollLock.test.ts.
  • spec:AST-013 FR10 still needs the real-device iOS Safari check described in the PR body.

[Automated review]

Comment on lines +186 to +192
if (!observedDialogs.has(dialog) && typeof MutationObserver === 'function') {
observedDialogs.add(dialog);
new MutationObserver(() => {
if (!dialog.open) {
leaveGutterFallback(dialog);
}
}).observe(dialog, {attributes: true, attributeFilter: ['open']});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On engines without scrollbar-gutter (Safari 17.0-18.1), this observer is the only way a dialog leaves the shared padding hold, and it never fires for a sheet that unmounts while presented: neither host closes its dialog on unmount, and removing an open <dialog> from the document keeps its open attribute. The detached dialog then stays in the member set, so <body> keeps its padding-right for the rest of the page's life and later sheets cannot release it (reproduced for both BottomSheet and BottomSheetSwitcher; the useScrollLock path released it). Releasing the membership and disconnecting the observer when the host unmounts, for example from a layout-effect cleanup in both hosts, would fix it, and an unmount-while-presented case in sheetScrollLock.test.ts would cover it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on a maintainer decision: this scroll lock rests on component:BottomSheet FR5 and DEC-2, which exist only in the draft spec PR #7744, so a maintainer needs to approve or amend that record (the CSS lock keyed off :modal, the scrollY and nested-scroller changes, and the document-lifetime stylesheet) before this can land. The padding leak on unmount from the previous revision is fixed. Independently of that decision, these still need work:

  • Without scrollbar-gutter, a sheet presented while another is still closing never joins the padding hold, so the page shifts 15px when the first sheet finishes its exit (sheetScrollLock.ts:219-228); keep the hold for every modal sheet dialog and test a second present made after the scrollbar is already hidden.
  • The new unmount-only release effects end the hold under StrictMode replay without the presentation effects re-joining it, and the per-dialog MutationObserver is never disconnected, so the release should live with the acquisition.
  • spec:AST-013 FR10 still needs the real-device iOS Safari check described in the PR body.

[Automated review]

} else {
dialog.removeAttribute(SHEET_HOLDS_GUTTER_ATTRIBUTE);
}
if (!takesSpace || supportsScrollbarGutter(doc)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On an engine without scrollbar-gutter, a dialog presented while another sheet dialog already holds the lock measures no scrollbar here (the first lock hid it, so clientWidth equals innerWidth), returns early and never joins the hold. When the first dialog closes, leaveGutterFallback releases the padding while this one is still modal, and the page shifts 15px. Once a document's hold exists, every modal sheet dialog should join it whatever it measures.

settleFirst();
expect(document.body.style.paddingRight).toBe('15px');

// A second modal sheet dialog (a handoff) joins the same hold.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This second present still sees clientWidth 1265, but once the first lock has hidden the scrollbar on an engine without scrollbar-gutter the layout viewport reports 1280. With clientWidth at 1280 for this call the second dialog does not join, and the padding is gone after the first dialog closes.

// A sheet unmounted while presented takes its dialog out of the document
// with it; the CSS lock releases on its own (the dialog is no longer
// `:modal`), but the scrollbar-gutter fallback is script and must be told.
useLayoutEffect(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under StrictMode replay this mount-only cleanup ends the padding hold, but the presentation effect does not re-join because dialog.open is already true, so a sheet mounted open loses its padding (architecture:react-component-runtime INV3). Releasing from the effect that acquired the hold avoids that; BottomSheetSwitcher.tsx:302 has the same shape.

…al; each BottomSheetSwitcher step owns its native dialog

Records two owner decisions in the BottomSheet and BottomSheetSwitcher
component records. BottomSheet FR5/DEC-2: while a sheet dialog is modal the
page does not scroll, <body> gains no inline styles, the scroll offset is
unchanged on release, and a classic scrollbar's gutter is held only when it
takes layout space; the lock follows the dialog's own modal state. The
mechanism is the implementation's, not a public contract.
BottomSheetSwitcher DEC-1: each step owns its own native dialog; the switcher
coordinates presentation, retention, the one-scrim handoff, re-present on
reversal, flow-level focus return, and purpose-gated cancel, with native
modal focus containment and a switcher-owned isolated wrapper for non-modal
ordering. DEC-2: the host dialog's identity is implementation, not public
contract; the hosting change is nonbreaking.

Rewrites the claims that described the shared host and the body-pin lock
(Switcher FR2, FR5, FR8, FR9, PR1, the Dialog surface concept and states;
BottomSheet ownership, states, and a new FR5) as decided requirements. The
anatomy keeps its shipped name until the implementation lands.
…dialog

Every sheet dialog carries data-sheet-dialog and one stylesheet, installed
once per document, freezes the page's scroller exactly while a sheet dialog
is modal: through a switcher handoff, beneath a Dialog opened from a sheet,
and released with the last modal sheet dialog. No counter, no saved scroll
offset, no inline styles on <body>. The rule freezes the root, or <body> on
a page whose <body> owns the viewport scroll, so the page keeps its offset
either way. The scrollbar gutter is held open only when a classic scrollbar
took layout space when the sheet was presented, so a non-scrolling page gets
no undimmed strip; where the engine has no scrollbar-gutter the shared
gutter helper pads <body> by the width it grew, joined by every presented
modal sheet dialog and released, through paired effect cleanups, with the
last one.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on a maintainer decision: this scroll lock rests on component:BottomSheet FR5 and DEC-2, which exist only in the draft spec PR #7744, so a maintainer needs to approve or amend that record (the CSS lock keyed off :modal, the scrollY and nested-scroller changes, the <body> padding fallback and the document-lifetime stylesheet) or name other governing spec. Whatever that decision, two things remain before this can be approved:

  • Where scrollbar-gutter is unsupported (Safari 17.0–18.1), closing a standalone sheet paints one frame with the scrollbar back and the 15px <body> padding still applied, because the padding hold is left only in a passive effect after dialog.close(); releasing it where the dialog closes, as the switcher's layout effects already do, removes the flash.
  • spec:AST-013 FR10 needs a run on Safari on the latest stable iOS, which the PR's own iOS check steps already describe.

[Automated review]

Comment on lines +361 to +367
useEffect(() => {
const dialog = dialogRef.current;
if (dialog == null || !(shouldPresent && hasScrim)) {
return;
}
return acquireSheetScrollLock(dialog);
}, [hasScrim, shouldPresent]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where scrollbar-gutter is unsupported, this cleanup is the only way a standalone sheet leaves the padding hold, and it runs only after React commits isPresented: false (then a microtask). handleMotionComplete has already called dialog.close() by then, so the CSS lock has let the classic scrollbar back while <body> still carries the 15px padding. In Chromium with classic scrollbars and scrollbar-gutter disabled, the frame after the close shows both, and the page content narrows from 1265px to 1250px for that frame; the switcher avoids it by closing and leaving in layout effects. Calling the release right after dialog.close() (keeping this cleanup as the backstop, since a second leave is a no-op) closes the gap.

@imdreamrunner
imdreamrunner force-pushed the spec/bottom-sheet-per-sheet-dialog branch from b578bbd to e79eac5 Compare October 21, 2026 01:19
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

Closing: BottomSheet keeps the same page scroll lock as Dialog (the shared hook) rather than a separate CSS lock. #7720 now stacks directly on the spec PR #7744.

@github-actions
github-actions Bot deleted the feat/bottom-sheet-css-scroll-lock branch October 21, 2026 06:22

This branch was successfully deployed

1 active deployment
Preview — 5d75a18d Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant