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…

"use client"

import * as React from "react"

/**
 * The re-entrant count and the snapshot of the inline styles taken at first lock live
 * in `document.body` data attributes rather than module-level variables. The count is
 * deliberately not held in one `useScrollLock` call's closure — two components (one
 * overlay stacked on another, say) may ask for the lock at the same time: body is
 * restored only once every caller has released and the count really reaches zero, and
 * one of them releasing early just decrements it.
 *
 * Why the count then moves all the way up onto body: this hook shares a page with
 * overlay components (Drawer / Popover / LoadingOverlay…) that each embed their own

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-scroll-lock.json

Prompt

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, sets overflow: hidden, reads it again, and pads paddingRight by the difference. Predicting the gap with innerWidth - clientWidth looks equivalent and is not: under scrollbar-gutter: stable the 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 / paddingRight already existed on body before 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 imperativeuseScrollLock(locked) takes a plain boolean the caller already tracks (e.g. isOpen); there's no lock() / unlock() API to call yourself.
  • Programmatic scroll is not a testoverflow: hidden blocks user scrolling but not window.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 compare window.scrollY.
  • iOS touch-scroll leakoverflow: hidden alone does not stop touch scrolling on iOS Safari, a known platform quirk that's documented rather than silently left broken.

On This Page