Hooks
useScrollLock
A controlled body-scroll lock hook with measured scrollbar compensation and a body-level lock count that cooperates with overlays carrying their own copy of the lock.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-scroll-lock.jsonPrompt
Build a React + TypeScript "useScrollLock" hook (no dependencies beyond
React; uses browser DOM APIs only — `document.body`, `window.innerWidth`,
`document.documentElement.clientWidth`).
Contract
- `useScrollLock(locked: boolean): void` — a controlled boolean, not an
imperative lock()/unlock() pair. The caller owns the `locked` state (e.g.
"is my modal open") and the hook's effect reacts to it.
Behavior
- All lock state lives on `document.body` itself, in three data attributes,
and nowhere else: `body.dataset.zyScrollLocks` (the reentrancy count, i.e.
the `data-zy-scroll-locks` attribute), `body.dataset.zyScrollLockOverflow`
and `body.dataset.zyScrollLockPadding` (the inline values captured by
whichever caller locked first). Do NOT keep any of this in module-level
`let` variables — the "Reentrancy" note below is the single most important
decision in this file.
- On the 0 → 1 edge (the first lock): snapshot body's *current* inline
`overflow` and `paddingRight` into those two attributes — the current
values, not an assumed empty string, since another script may already have
written inline styles onto body — and then apply the lock.
- Applying the lock, in this exact order: read
`document.documentElement.clientWidth`, set `document.body.style.overflow =
"hidden"`, read `clientWidth` again, and add the difference to body's
computed `paddingRight` only when it is greater than zero.
MEASURE the shift, never predict it. The obvious
`window.innerWidth - document.documentElement.clientWidth` shortcut is
wrong on any page that sets `scrollbar-gutter: stable`: there the gutter is
permanent, so hiding the scrollbar does not widen anything, yet the
subtraction still reports ~15px — you pad space that was never reclaimed and
the content jumps LEFT the moment the overlay opens, which is precisely the
jump this compensation exists to prevent. Measuring the real before/after
difference is correct for classic scrollbars, stable gutters and overlay
scrollbars alike, and it means the hook never has to know which of those the
host page uses.
Worth knowing before you "verify" this: macOS ships overlay scrollbars by
default (zero width), so the difference measures 0 and this branch never
fires there. Testing on a Mac alone proves nothing about the compensation —
check on Windows / Linux with classic scrollbars, both with and without
`scrollbar-gutter: stable`.
- Every subsequent lock only increments the count. Every release decrements
it; only the 1 → 0 edge writes the two snapshots back and deletes all three
data attributes, leaving body exactly as it was found.
- Reentrancy, and why the count sits on the DOM rather than in a module:
several callers may hold the lock at once (a drawer with a popover inside
it), and a caller releasing early must only decrement, never unlock while
another still holds it. A module-level counter gets that right only as long
as everybody shares one module — and here nobody does. This hook is
distributed as a file you copy into your project, and so are the overlay
components that inline the very same lock (drawer, popover, modal loading
overlay, command palette, shortcuts sheet…); a buyer may install any subset
of them, so one page routinely runs several independent copies, each with
its own private module scope, each blind to the others. Nest two of them and
the page dies: the outer overlay snapshots `""`, the inner one snapshots
`"hidden"`, the outer closes first and restores `""` (fine so far), then the
inner closes and writes back the `"hidden"` it believed was the original —
the page is now unscrollable with no overlay left on screen to blame.
`document.body` is the one namespace every copy can already reach without
importing anything, which is what makes independent copies cooperate. The
three attribute names ARE the protocol: keep them byte-identical everywhere
you paste this code, or the copies stop seeing each other again.
- Verifying that the unlock actually worked: `overflow: hidden` does not stop
*programmatic* scrolling, so `window.scrollBy(0, 600)` moves a fully locked
page and happily reports success on a broken build. Verify with a real wheel
gesture (Playwright's `page.mouse.wheel`, or an actual trackpad) and compare
`window.scrollY` before and after.
- Known limitation to document, not silently paper over: on iOS Safari,
`overflow: hidden` does not fully prevent touch scrolling (touch scroll can
"leak through", especially when a scrollable child sits inside the locked
area). A complete fix requires switching body to `position: fixed` with the
scroll position saved/restored manually, which introduces its own problem
(other `fixed` / `sticky` elements on the page shift during the lock and
need `top` compensation). Do not implement that by default — call it out
as an opt-in lever instead.
Rendering & styling
- The hook renders nothing and owns no visual output — it only mutates
`document.body.style`. There is no className/JSX surface, so no semantic
tokens apply here.
Customization levers
- iOS `position: fixed` patch — if you must guarantee the lock on iOS
Safari, swap the `overflow: hidden` branch for saving `window.scrollY`,
setting `position: fixed; top: -${scrollY}px; width: 100%`, and restoring
`scrollTo(0, scrollY)` on unlock. Left out by default because it needs
extra bookkeeping for other fixed/sticky elements on the page.
- Target-element variant — this hook always locks `document.body`. To lock
an arbitrary scroll container instead, parametrize the target
(`useScrollLock(locked, targetRef)`) and read/write that element's
`overflow` / `paddingRight` instead of `document.body`'s.
- Scrollbar compensation needs no opt-out: because the shift is measured
rather than assumed, a page that never reclaims width when the scrollbar
goes away (`scrollbar-gutter: stable`, a permanently `overflow-y: scroll`
body, macOS overlay scrollbars) measures 0 and gets no padding at all. Drop
the whole `paddingRight` branch only if you also stop restoring it.
- Lock protocol: the three `data-zy-scroll-*` attribute names are shared with
every overlay component that inlines this same lock. Rename them only if you
rename them in all of those copies too — diverging names means two
independent counters again, which is the bug this design exists to prevent.Concepts
- Measured scrollbar compensation — the hook reads
document.documentElement.clientWidth, setsoverflow: hidden, reads it again, and padspaddingRightby the difference. Predicting the gap withinnerWidth - clientWidthlooks equivalent and is not: underscrollbar-gutter: stablethe gutter never goes away, so the prediction pads ~15px of width that was never reclaimed and the page jumps left on open — the same jump, mirrored. Measuring after the fact is right for classic scrollbars, stable gutters and macOS overlay scrollbars alike, with zero knowledge of the host page. - Snapshot-and-restore, not clear — the hook records whatever inline
overflow/paddingRightalready existed onbodybefore locking and restores exactly that value afterward, instead of assuming an empty baseline that could stomp on styles another script had already set. - The counter lives on
document.body, not in a module — the count and the snapshot are three data attributes (data-zy-scroll-locks,data-zy-scroll-lock-overflow,data-zy-scroll-lock-padding). This is the whole design. Everything here is installed as a copy, so one page runs several independent copies of this same lock — this hook plus a drawer plus a popover, each in its own module scope, each unable to see the others'let. Nest two overlays with private counters and the page dies: the outer one restores""on close, then the inner one writes back the"hidden"it recorded as "the original", and nothing on screen is left to explain why scrolling stopped. A DOM attribute is the one namespace every copy already shares, so the lock is applied on the true 0→1 edge and released on the true 1→0 edge no matter who owns which layer. Shared names are the contract — rename in one copy and you are back to two counters. - Controlled boolean, not imperative —
useScrollLock(locked)takes a plain boolean the caller already tracks (e.g.isOpen); there's nolock()/unlock()API to call yourself. - Programmatic scroll is not a test —
overflow: hiddenblocks user scrolling but notwindow.scrollBy, so a scripted scroll succeeds on a page that is completely frozen for a real user. A regression test for the release path has to use a real wheel gesture (page.mouse.wheel) and comparewindow.scrollY. - iOS touch-scroll leak —
overflow: hiddenalone does not stop touch scrolling on iOS Safari, a known platform quirk that's documented rather than silently left broken.