2026年8月19日

React useScrollLock Hook:為彈窗鎖住頁面滾動 (2026)

彈窗打開了,居中、漂亮、無可挑剔。然後有人在遮罩上一劃,背後的整頁內容就從彈窗底下滾走了。所有人第一次的修法都是同樣三行:

useEffect(() => {
  document.body.style.overflow = open ? "hidden" : "";
}, [open]);

在你自己的筆記本上完全正常。然後 bug 報告來了:

  1. iPhone 上頁面照樣能動。 即使 <body> 上有 overflow: hidden,iOS Safari 的觸控拖動依然會橡皮筋滾動整個文件。
  2. 順手抹掉了別的東西。 "" 不一定是原本的值——你剛剛擦掉了設計系統或 CSS-in-JS 寫在行內的那個 overflow
  3. 兩個浮層,一個凍住的頁面。 抽屜和圖片燈箱都在改 body.style.overflow;關閉順序一顛倒,頁面就再也滾不動了。
  4. 桌面端捲軸一消失,整頁佈局就抖一下。

來自 @reactuses/coreuseScrollLock 就是這三行,但把難的部分都處理掉了:它會還原自己替換掉的那個行內 overflow,在 iOS 上加一層 touchmove 守衛、同時仍然讓彈窗自己的內容能滾,把鎖定狀態作為 React state 暴露出來供你渲染,並且可以作用在任意元素上——不只是 <body>。本文逐行講清它到底做了什麼、為什麼在 iOS 上 overflow: hidden 不夠、它和 position: fixedbody:has(dialog[open]) 兩種方案怎麼比,以及真實專案裡會踩的六個坑。

快速開始

npm install @reactuses/core
import { useScrollLock } from "@reactuses/core";
import { useEffect } from "react";

function Modal({ open, onClose, children }: ModalProps) {
  // 傳 getter,不要直接傳 document.body —— 見下面的 SSR 那條坑
  const [, setLocked] = useScrollLock(() => document.body);

  useEffect(() => {
    setLocked(open);
    return () => setLocked(false); // 即使在開啟狀態下卸載也會釋放
  }, [open, setLocked]);

  if (!open) return null;

  return (
    <div className="overlay" onClick={onClose}>
      <div className="sheet" onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}

簽名:

const [locked, setLocked] = useScrollLock(target, initialState?)
  • target —— 要鎖住滾動的那個元素。接受元素本身、RefObject,或者 getter () => element。每次呼叫都是惰性解析的。
  • initialState —— 初始就鎖住。預設 false,而且你應該保持預設(見坑 3)。
  • 返回 [locked, setLocked]locked 是真正的 state;setLocked 引用穩定,放依賴陣列或當 prop 傳都安全。

useScrollLock 到底做了什麼

從原始碼濃縮出來的核心:

const [locked, setLocked] = useState(initialState);
const initialOverflowRef = useRef<CSSStyleDeclaration["overflow"]>("scroll");

useEffect(() => {
  const element = getTargetElement(target);
  if (element) {
    initialOverflowRef.current = element.style.overflow; // 記住我們要替換掉的值
    if (locked) element.style.overflow = "hidden";
  }
}, [locked, target]);

const lock = useEvent(() => {
  const element = getTargetElement(target);
  if (!element || locked) return;
  if (isIOS) element.addEventListener("touchmove", preventDefault, { passive: false });
  setLocked(true);
});

const unlock = useEvent(() => {
  const element = getTargetElement(target);
  if (!element || !locked) return;
  if (isIOS) element.removeEventListener("touchmove", preventDefault);
  element.style.overflow = initialOverflowRef.current; // 還原,而不是覆蓋成空
  setLocked(false);
});

裡面有四個決策值得點名,因為手寫版本恰恰就是在這幾處不一樣:

  • 鎖定是 state,不是「發出去就不管」的副作用。 locked 是真正的 useState 值,所以驅動樣式的那個布林值同時也能驅動你的 aria-hidden、className、Esc 處理邏輯。
  • 它還原自己替換掉的行內值,而不是 ""。如果原本行內是 overflow: overlay,還原回來的就是它。
  • target 是惰性解析的,走 getTargetElement,沒有 window 時返回 undefined。服務端不會碰 DOM。
  • 只有 iOS 會加 touchmove 守衛。 而這正是真正有意思的部分。

為什麼在 iOS 上 overflow: hidden 不夠

給滾動元素加 overflow: hidden 是規範認可的、正確的停止滾動方式——但 iOS Safari 從來沒有在 <body> 上完全遵守它,觸控拖動依然能橡皮筋滾動文件。唯一可靠的辦法是取消手勢本身:

element.addEventListener("touchmove", preventDefault, { passive: false });

這裡的 passive: false 是必需的,不是裝飾。瀏覽器預設把文件級目標上的 touch 監聽註冊為 passive,而 passive 監聽裡的 preventDefault() 會被忽略並在控制台給一條警告——你的鎖會靜默失效。

但在 touchmove 上無腦 preventDefault 會毀掉你真正想要的東西:彈窗內部的滾動。所以處理函式在取消之前先問一個問題:

function checkOverflowScroll(ele: Element): boolean {
  const style = window.getComputedStyle(ele);
  if (
    style.overflowX === "scroll" || style.overflowY === "scroll"
    || (style.overflowX === "auto" && ele.clientWidth < ele.scrollWidth)
    || (style.overflowY === "auto" && ele.clientHeight < ele.scrollHeight)
  ) return true;

  const parent = ele.parentNode as Element;
  if (!parent || parent.tagName === "BODY") return false;
  return checkOverflowScroll(parent);
}

event.target 往上走,只要有任一祖先是真的可滾動的——overflow: scroll,或者 overflow: auto 且此刻內容真的溢位——就放這個手勢過去,一點都不攔。由此自然帶來兩個很舒服的性質:

  • 一個 overflow: auto 容器如果當前內容裝得下,它就不是可滾動的,於是會被鎖住——這是對的。內容變多了它自己就又能滾了,不需要改程式碼。
  • 多指觸控被排除在外(if (e.touches.length > 1) return true,在任何 preventDefault 之前),所以雙指縮放照樣能用。在彈窗裡禁掉縮放是無障礙上的退步,這裡繞開了它。

useScrollLock 與另外四種方案對比

方案攔住 iOS 橡皮筋保留內部滾動保留滾動位置代價
手寫 body.style.overflow = "hidden"覆蓋行內樣式,且從不還原
body:has(dialog[open]) { overflow: hidden }零 JS —— 但 iOS 的洞一模一樣
body { position: fixed; top: -scrollY }只有你自己儲存並還原才行<body> 拽出正常流:position: fixed 的子元素重新定位,滾動錨定和 scroll-behavior: smooth 都會變怪
dialog ::backdrop 上寫 overscroll-behavior: contain✅(Chrome 144+)支援的地方最乾淨 —— 但只適用於 <dialog>
useScrollLock一次 hook 呼叫背後約 40 行 JS

有一點很容易讓人誤會:<dialog>.showModal() 會把文件其餘部分變成 inert——點選和 Tab 都進不去——但它不能可靠地阻止滾動,在移動端觸控下尤其如此。惰性(inert)和滾動鎖定是兩個不同的問題,瀏覽器只幫你解決了第一個。

還有一個是互補而非替代:給你內部滾動容器加 overscroll-behavior: contain 能阻止滾動鏈式傳遞——內層列表滾到底之後把手勢交給頁面。不管你用哪種鎖法,這個都值得加上;但它單獨並不能攔住從遮罩上開始的那一劃。

實戰模式

1. 宣告鎖定,而不是手動開關

快速開始裡的寫法就是值得記住的模式。不要在開啟的 handler 裡寫 setLocked(true)、在關閉的 handler 裡寫 setLocked(false)——那是兩個會忘的地方,中間還有各種提前 return 的分支——而是把鎖綁到本來就描述彈窗的那個 state 上:

useEffect(() => {
  setLocked(open);
  return () => setLocked(false);
}, [open, setLocked]);

這樣鎖定就不可能和 UI 脫節,而 cleanup 還覆蓋了命令式寫法總會漏掉的那個 case:彈窗還開著的時候路由跳走、元件被卸載。

配合 useDisclosure 來管開關狀態本身:

import { useDisclosure, useScrollLock } from "@reactuses/core";
import { useEffect } from "react";

function Drawer({ children }: { children: React.ReactNode }) {
  const { isOpen, onOpen, onClose } = useDisclosure();
  const [locked, setLocked] = useScrollLock(() => document.body);

  useEffect(() => {
    setLocked(isOpen);
    return () => setLocked(false);
  }, [isOpen, setLocked]);

  return (
    <>
      <button onClick={onOpen}>選單</button>
      <main aria-hidden={locked}>{/* 頁面內容 */}</main>
      {isOpen && (
        <aside className="drawer">
          {children}
          <button onClick={onClose}>關閉</button>
        </aside>
      )}
    </>
  );
}

注意元組裡 locked 這一半是怎麼發揮作用的:一個布林值同時驅動樣式和無障礙狀態,所以它們不可能對不上。(在 React 19 上同一個值還能直接給 inert。)

2. 鎖住滾動容器,而不是文件

很多應用根本不滾動文件——外殼是 height: 100vh; overflow: auto,一切都在一個 div 裡滾。這種情況下給 <body>overflow: hidden 完全沒有作用,不知道這點的話能耗掉你一下午。把 hook 指向真正的滾動容器:

function Shell({ children }: { children: React.ReactNode }) {
  const scroller = useRef<HTMLDivElement>(null);
  const [, setLocked] = useScrollLock(scroller);

  return (
    <div ref={scroller} style={{ height: "100vh", overflow: "auto" }}>
      {children}
    </div>
  );
}

同一個 hook,同一個元組。這也是為什麼 target 是必填而不是預設 document.body:庫無法知道哪個元素才是你的滾動根。

3. 拖拽期間鎖定

觸控拖動滑塊、可排序列表或自定義輪播時,頁面會跟著滾,除非有東西攔住它——而 touchmove 守衛恰好就是對的工具:

const [, setLocked] = useScrollLock(() => document.body);

<div
  onPointerDown={() => setLocked(true)}
  onPointerUp={() => setLocked(false)}
  onPointerCancel={() => setLocked(false)}
/>

onPointerCancel 很關鍵:瀏覽器可能在手勢中途搶走指標,沒有它你就會把頁面鎖死。如果你是在自己實現拖拽而不只是接一個現成的,useDraggable 已經把指標那套賬都記好了。

值得知道的坑

1. 鎖定是一個樣式,不是一段生命週期

鎖是寫在一個 hook 並不擁有的元素上的行內 overflow: hidden,總得有人把它放回去。從 @reactuses/core v6.5.3 起,持有鎖的元件卸載時 hook 會自己做這件事:還原它替換掉的那個行內值,並摘掉 iOS 的 touchmove 守衛——所以「彈窗還開著就跳路由」不會再把頁面凍住。v6.5.2 及更早版本不會還原,如果你鎖在舊版本上就得留意:在 iOS 上殘留的那個 passive: false 監聽會把整個工作階段剩下的觸控捲動全部幹掉,不只是樣式的問題。

不過卸載只是一半。另一半——元件還掛著、只是彈窗關了——無論哪個版本都得你自己管,這也正是上面那個模式把 setLocked 綁到 open 上並帶 cleanup、而不是在兩個 handler 裡分別開關的原因:

useEffect(() => {
  setLocked(open);
  return () => setLocked(false);
}, [open, setLocked]);

把 setter 當成你借來的一個樣式。每一次借都要還。

2. 一個元素只能有一個持有者

兩個 hook 例項鎖同一個元素是最微妙的失效方式,因為每個例項都記著自己那份原始 overflow

A.lock()    → overflow: hidden    (A 記住的是 "auto")
B.lock()    → overflow: hidden    (B 記住的是 "hidden" 😬)
A.unlock()  → overflow: auto      (頁面能滾了,儘管 B 還認為自己鎖著)
B.unlock()  → overflow: hidden    (現在頁面卡死了,而且什麼都沒開啟)

這裡沒有什麼能救你——這是「儲存舊值、再放回去」這個思路本身固有的問題,所有手寫方案和大多數庫都一樣。答案在架構層面:每個元素只有一個鎖的持有者。useScrollLock(() => document.body) 放在佈局、Provider 或者 store 裡,讓各個彈窗去請求它加鎖,而不是各自帶一個。

3. initialState: true 會跳過 iOS 守衛

useScrollLock(target, true) 會從第一次提交起就加上 overflow: hidden——但 touchmove 監聽只在 lock() 裡掛,而 lock() 從沒跑過。所以一個初始就鎖定的頁面在 iOS 上依然能橡皮筋滾。從 false 開始,然後翻過去:

const [, setLocked] = useScrollLock(() => document.body);
useEffect(() => { setLocked(true); }, [setLocked]); // 掛載即鎖定,守衛也帶上了

4. 桌面端佈局抖動

捲軸一藏,就騰出約 15px,整頁橫向抖一下。這不是 hook 該管的事,一行 CSS 就夠:

html { scrollbar-gutter: stable; }

5. SSR 下要傳 getter,不要傳 document.body

useScrollLock(document.body) 會在渲染期間求值 document.body,在服務端還沒輪到 hook 小心行事就已經拋錯了。() => document.body(或一個 ref)只在 effect 和 handler 裡被讀到,而那裡 getTargetElement 早已在沒有 window 時直接返回:

const [, setLocked] = useScrollLock(() => document.body); // ✅ SSR 安全
const [, setLocked] = useScrollLock(document.body);       // ❌ 服務端崩

庫裡所有接受元素 target 的 hook 都是同一條規則,這也是 Next.js / Remix 專案裡最常見的 SSR 失誤。

6. hidden 攔手勢,不攔程式化滾動

一個 overflow: hidden 的盒子依然可以通過 scrollTopscrollToscrollIntoView 滾動——更關鍵的是,瀏覽器會為了把新獲得焦點的元素帶進視口而滾動它。如果焦點跑到了彈窗背後的某個連結上,你「鎖住」的頁面會滾過去。滾動鎖定和焦點陷阱是同一個功能的兩半,兩個都要做。

什麼時候不該用 useScrollLock

  • 你只想阻止內層滾動容器把滾動傳遞給頁面 → CSS 的 overscroll-behavior: contain,一行 JS 都不用。
  • 你在用 <dialog> 且可以要求 Chrome 144+ → 在 dialog 和它的 ::backdrop 上寫 overscroll-behavior: contain,比任何 hook 程式碼都少。
  • 你想滾動到某個東西useScrollIntoView,或者原生那一行——昨天講 scrollIntoView + useRef 的那篇把兩種都覆蓋了。
  • 你想讀取或響應滾動位置useScrolluseWindowScroll
  • 你想要的是真正沉浸、無瀏覽器外框的檢視 → 用 useFullscreen,而不是鎖一個滾動容器。
  • 你在隨著滾動載入更多資料useInfiniteScroll;那裡最不需要的就是一把鎖。

要點回顧

  • overflow: hidden 在桌面端是正確的機制,在 iOS Safari 上則是不完整的——只有取消 touchmove(並且 passive: false)才真的能讓文件停止橡皮筋。
  • useScrollLock 把這層守衛和一個「祖先是否真的可滾動」的判斷配在一起,於是頁面動不了、而彈窗自己的內容照樣能滾——多指縮放也活著。
  • 它還原自己替換掉的那個行內 overflow,把鎖定作為可渲染的 state 暴露出來,並且能作用在任意元素上——當你的應用滾在一個 div 而不是文件裡時,這正是你需要的。
  • 把鎖綁到描述 UI 的那個 state 上(setLocked(open) 加一個 cleanup),每個元素只留一個持有者initialState 保持 false,SSR 下傳 getter,再用 scrollbar-gutter: stable 處理桌面端抖動。
  • 滾動鎖定只是彈窗的一半。焦點也要陷住,否則一旦浮層背後的東西拿到焦點,hidden 的頁面照樣會滾。

useScrollLockuseDisclosureuseScrollIntoView 以及另外 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——一次安裝,支援 tree-shaking,沒有需要你操心的依賴。

npm install @reactuses/core