Skip to content

fix: hold the scrollbar gutter while an overlay is open - #5240

Closed
imdreamrunner wants to merge 1 commit into
mainfrom
css-first-scrollbar-gutter
Closed

fix: hold the scrollbar gutter while an overlay is open#5240
imdreamrunner wants to merge 1 commit into
mainfrom
css-first-scrollbar-gutter

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Refines #5219. Same trigger — the gutter is taken only while an overlay is open — but two things change.

1. scrollbar-gutter: stable instead of padding

Padding an element only holds content in the document flow. position: fixed chrome — a toast viewport, a sticky dock — resolves against the viewport and kept jumping the full 15px. Holding the gutter is a real layout change, so everything stays put.

It also can't double-compensate: #5219 measured the scrollbar's width and padded by it, which on a page that already sets scrollbar-gutter: stable shifted content 15px the wrong way. The gutter is idempotent.

2. Applied dynamically, never statically

An earlier revision of this PR put html { scrollbar-gutter: stable } in reset.css. That fixes more — navigation jank, content-growth jank, the 100vw overflow bug — but it reserves the gutter for the whole life of every document, including pages too short to scroll, which never had the shift in the first place.

On a one-screen full-bleed marketing page that is a visible regression, measured at 900×600 with classic scrollbars:

without with static rule
hero width 900 885
fixed nav right edge 900 885
headline center 450 443
100vw band 900 885

The full-bleed gradient stops 15px short and the page background shows through as a strip down the right edge. So the rule is out of reset.css; the gutter is taken at lock time and given back on close.

The whole decision is one boolean

if (window.innerWidth > root.clientWidth) {
  root.style.scrollbarGutter = 'stable';   // CSS does the rest
}

No widths, no arithmetic, no padding to restore. Nothing is applied where there is no gutter to hold: overlay scrollbars (mobile, macOS by default) and pages too short to scroll.

Test plan

49/49 on the real components, with no static rule loaded so this path is the only mechanism — Dialog × BottomSheet × MobileNav, across four page shapes and four environments, plus nested overlays closed out of order:

tall short RTL page reserves its own gutter
classic scrollbars
overlay scrollbars
iPhone 15
Pixel 7

Every cell: 0px for in-flow content, 0px for position: fixed content, background scroll still locked, layout and the page's own scrollbar-gutter value restored on close.

And the case that drove the design — the short full-bleed marketing page, with this fix installed: hero 900, nav 900, headline centered 450, band 900, computed gutter auto. Byte-identical to a build with no fix at all.

Unit coverage: the helper (hold, release, overlay scrollbars, short pages, no-layout environments, nesting, out-of-order release, double-release, restoring the page's own value), the hook, and the drawer.

Gates: pnpm lint:strict (0 errors, 56 pre-existing warnings), pnpm test (7,384 core+lab; 3,407 cli+apps), pnpm build.

Known limitation

Engines without scrollbar-gutter (Safari <18.2) get no compensation — #5219's padding fallback is gone with the padding. Safari defaults to overlay scrollbars, where there is no shift to fix; only Safari <18.2 with "always show scroll bars" turned on is affected, and it degrades to today's behavior rather than anything worse.

Not doing (for now)

The static rule fixes two janks this doesn't: navigating short page → scrolling page, and content growing past the viewport (a list loads, an accordion opens). Both shift by the same 15px. If we want those, the static rule is the answer — but it's a layout change for every consumer on upgrade and belongs in its own PR, marked [breaking] for the minor bump, not smuggled in with a modal fix.

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

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 20, 2026 1:52am

Request Review

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

MobileNav (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 439 -
Complexity N/A Very High (51) -

Bundle Size Summary

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

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

github-actions Bot added a commit that referenced this pull request Aug 20, 2026
…lback

Refines #5219 rather than replacing its trigger. Two changes:

1. `scrollbar-gutter: stable` instead of padding. It is a real layout
   change, so it holds position: fixed chrome -- toast viewports, sticky
   docks -- which padding cannot: fixed elements resolve against the
   viewport, not against the padded element.

2. Applied for the duration of the lock only, never statically. A
   static rule in reset.css also fixes the shift (and fixes navigation
   jank too), but it reserves the gutter for the life of every
   document, including pages too short to scroll -- which never had the
   shift. On a one-screen full-bleed marketing page that permanently
   narrows the layout and pulls full-bleed art off the right edge.
   Measured: hero 900 -> 885, headline re-centered 7px left.

The whole decision is one boolean -- does the scrollbar take layout
space -- and CSS does the rest. No widths, no arithmetic, no padding to
restore, and nothing at all applied on mobile or on short pages.

Verified on the real components with no static rule loaded: 49/49
across Dialog/BottomSheet/MobileNav x tall/short/RTL/page-reserves-its-
own-gutter x classic scrollbars, genuine overlay scrollbars, iPhone and
Pixel profiles, plus nested overlays closed out of order. The short
full-bleed marketing page renders byte-identically to a build with no
fix at all.

Engines without scrollbar-gutter (Safari <18.2) get no compensation
now. Safari defaults to overlay scrollbars, where there is no shift to
fix.
@imdreamrunner
imdreamrunner force-pushed the css-first-scrollbar-gutter branch from 6567f0c to 895f4c4 Compare August 20, 2026 01:49
@imdreamrunner imdreamrunner changed the title fix: hold the scrollbar gutter stable in CSS, drop the JS compensation fix: hold the scrollbar gutter while an overlay is open Aug 20, 2026
@github-actions github-actions Bot removed the needs:design-review Affects visuals — Design should review label Aug 20, 2026
github-actions Bot added a commit that referenced this pull request Aug 20, 2026
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

Closing this — it turned out to be a no-op refactor with a regression, and #5219 already shipped the fix.

Why

#5219 landed as two commits, and the squash kept the first one's title (fix: reserve the scrollbar's width…), which reads like the padding-based version. The merged code is actually the second commit: dynamic scrollbar-gutter: stable, applied at lock time and released on close. So the two changes this PR advertised — "gutter instead of padding" and "dynamic, not static" — were both already on main.

Its only real delta was deleting the padding fallback. I diffed both builds over the same 36 cases (Dialog × BottomSheet × MobileNav, tall/short/RTL/page-reserves-its-own-gutter, classic + overlay scrollbars + iPhone):

engine result
Chrome / Edge / Firefox / Safari 18.2+ identical, all 36 cases
Safari 17.0–18.1 3 cases regress — a modal on a scrolling desktop page shifts 15px again (0px on main, via the padding fallback)

scrollbar-gutter needs Safari 18.2, and the browser-support doc lists Safari 17+ as Tier 2 "officially supported". Trading a Tier 2 regression for tidier internals — internals that aren't even exported — isn't worth it.

What main does today, for the record

holdScrollbarGutter() runs before either scroll lock hides the scrollbar:

  1. Gate on window.innerWidth > documentElement.clientWidth — "does the scrollbar take layout space". No → no-op, so overlay scrollbars, mobile, and pages too short to scroll are untouched.
  2. Hold scrollbar-gutter: stable on <html>.
  3. settle() re-measures and pads only if the element moved anyway — 0 on any engine that supports scrollbar-gutter, so no padding is written in practice.
  4. release() restores the previous value.

Verified live on main with classic scrollbars: content edge 885 → 885 → 885 across open/close, with stable present on <html> only while the dialog is open.

Left on the table

Two related janks that neither PR fixes, both the same 15px, both caused by the scrollbar appearing or disappearing:

  • navigating from a short page to a scrolling one
  • content growing past the viewport (a list loads, an accordion opens)

A static html { scrollbar-gutter: stable } in reset.css fixes both, plus the 100vw overflow bug. But it reserves the gutter for the life of every document — including short pages that never had the shift. On a one-screen full-bleed marketing page that measured hero 900 → 885, headline re-centered 7px left, and the full-bleed art pulled off the right edge. If we ever want it, it belongs in its own PR marked [breaking] for the minor bump, not bundled with a modal fix.

Also worth recording: scoping it with html:has(dialog[open]) does not work — it carves a gutter on short pages. There is no CSS selector for "has a scrollbar right now", and both scroll-state container queries and scroll-driven timelines flip off the instant you lock (a container query also cannot style its own container). Conditionality genuinely requires JS.

@imdreamrunner
imdreamrunner deleted the css-first-scrollbar-gutter branch August 21, 2026 02:05
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