2026年8月18日

React scrollIntoView + useRef:滾動到指定元素 (2026)

一個很長的表單。使用者點了提交,校驗在往下三屏的某個欄位掛了,錯誤提示渲染在了他根本看不見的地方。修起來只要調一個瀏覽器 API——但放在哪裡調傳什麼參數,能耗掉你一個下午。

先給結論,大部分人搜過來就是要這個:

import { useRef } from "react";

function Article() {
  const sectionRef = useRef<HTMLDivElement>(null);

  return (
    <>
      <button onClick={() => sectionRef.current?.scrollIntoView({ behavior: "smooth" })}>
        跳到詳情
      </button>
      {/* … 一大堆內容 … */}
      <div ref={sectionRef}>詳情</div>
    </>
  );
}

整個模式就這些:給元素掛個 ref,在事件處理裡調 .scrollIntoView(),加 ?. 是因為 React 提交之前 sectionRef.current 一直是 null。瀏覽器原生支援,零成本,對這種靜態錨點來說這就是正確答案——別上庫。

這篇講的是剩下的部分:那幾個參數到底做什麼、固定頭部遮擋問題(以及為什麼 CSS 的解法比 JavaScript 的好)、怎麼滾動到剛剛渲染出來的東西,還有原生呼叫真正做不到的四件事——到那一步,@reactuses/coreuseScrollIntoView 才配上場。

你手上真正有的參數

Element.scrollIntoView() 接一個可選的配置物件,就三個鍵:

參數取值預設值作用
blockstart · center · end · neareststart塊軸方向的對齊——常規書寫模式下就是垂直方向
inlinestart · center · end · nearestnearest行內軸方向的對齊——水平方向
behaviorauto · instant · smoothautoauto 跟隨滾動容器的 CSS scroll-behavior

所以值得背下來的就三種呼叫:

el.scrollIntoView();                                        // 頂到頂部
el.scrollIntoView({ behavior: "smooth", block: "center" }); // 平滑滑到中間
el.scrollIntoView({ block: "nearest" });                    // 只在看不見時才移動

block: "nearest" 是被低估的那個。它只滾動最小必要距離把元素帶進視口,元素已經可見時乾脆什麼都不做——這正是列表框鍵盤導航要的效果:每按一次方向鍵就重新居中,會讓列表感覺在跟你較勁。

還有個遺留的布林寫法:scrollIntoView(true) 等於 block: "start"scrollIntoView(false) 等於 block: "end"。到處都還能用,但物件寫法能自解釋。

有個點常讓人意外:scrollIntoView 會滾動所有可滾動的祖先,不只是最近的那個。如果你的元素在一個可滾動面板裡、面板又在可滾動頁面裡,兩個都會動,最終讓元素露出來。這基本上就是你想要的。

固定頭部:用 CSS,別用魔法數字

最高頻的追問:滾到某個標題,結果 64px 的固定頭部正好壓在上面。

第一反應是自己算:

// 別這麼寫
const top = el.getBoundingClientRect().top + window.scrollY - 64;
window.scrollTo({ top, behavior: "smooth" });

現在這個 64 歸你養了。移動端頭部更矮時它是錯的,頭部上方冒出一條促銷 banner 時它是錯的,元素在滾動容器而不是頁面裡時它是錯的——順帶你還放棄了 scrollIntoView 處理祖先容器的能力。

平臺就有一個專門幹這事的屬性:

.section {
  scroll-margin-top: 5rem; /* 或者 var(--header-height) */
}

scroll-margin-top 告訴瀏覽器:僅在滾動定位時把這個元素當作多了這麼多外邊距。然後一句樸素的 el.scrollIntoView({ behavior: "smooth" }) 就會提前 5rem 停住,佈局完全不受影響,而且這個值就寫在它依賴的頭部高度旁邊。它還順手修好了 :target 錨點和瀏覽器的頁內查詢定位——這兩個 JavaScript 版本永遠做不到。

優先用 scroll-margin-top。每次都是。

滾動到剛渲染出來的元素

問題的另一半是時機。你往列表里加一項,想滾過去;展開一個摺疊面板,想露出來;設定了一個錯誤,想跳過去。天真的寫法不管用:

// 有 bug:新行還不在 DOM 裡
function addRow() {
  setRows(r => [...r, newRow]);
  lastRowRef.current?.scrollIntoView(); // 還是**舊的**最後一行,或者是 null
}

setRows 只是排了一次渲染。React 會在之後提交——而在 React 18+ 的併發渲染下,這個”之後”確確實實不在這一個 tick 裡。那行程式碼執行的瞬間,DOM 還是舊的 DOM。

預設解法是用 effect。 在加進那一行的提交之後再滾:

useEffect(() => {
  lastRowRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [rows.length]);

如果你要的是瞬間滾動、並且希望它在瀏覽器繪製之前落位,就換成 useLayoutEffect——否則使用者會看到一幀停在舊位置,觀感上就是閃一下。平滑滾動則無所謂,動畫反正都要開始。

“剛建立出來的元素”用回呼 ref 更乾淨。 不用 effect、不用依賴陣列、也不用維護一個 ref——React 掛上節點的那一刻回呼就觸發:

const scrollOnMount = useCallback((node: HTMLElement | null) => {
  node?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, []);

// …
{rows.map((row, i) => (
  <Row key={row.id} ref={i === rows.length - 1 ? scrollOnMount : undefined} />
))}

flushSync 是逃生艙,不是預設選項。 如果你確實必須在改狀態的同一個事件處理裡滾動,可以強制同步提交:

import { flushSync } from "react-dom";

flushSync(() => setExpanded(true));
detailsRef.current?.scrollIntoView({ behavior: "smooth" });

能用,代價是放棄 React 替你做的批處理和併發排程。在處理函數里偶爾用一次沒問題;一個檔案裡出現三次就是味道不對了。

原生呼叫的天花板

對錨點、“滾到錯誤處”、列表鍵盤導航來說,上面這些已經夠了,你可以不用往下讀。原生真正做不到的有四件事:

1. 控制不了時長和曲線。 behavior: "smooth" 具體多快由瀏覽器說了算——Chrome 和 Firefox 不一樣,而且完全沒有旋鈕。如果這個滾動是一段編排好的轉場的一部分、必須和 400ms 的淡入對齊,那你辦不到。

2. 沒有可靠的”滾完了”回呼。 scrollend 事件就是為這個設計的,Chrome/Edge 114、Firefox 109 落地,Safari 更晚一些——依賴它之前先查一下支援度,而且它並不告訴你結束的是哪一次程式化滾動。大家退而求其次的那些做法(setTimeout 猜一個、輪詢 scrollY 直到不變),脆得跟聽起來一樣。

3. 取消不了。 開一段長距離平滑滾動,使用者中途抓住滾輪,瀏覽器照樣把他拖到目的地。長頁面上這是最招人煩的滾動 bug,沒有之一,而且沒有 API 能停下它。

4. 它不管 prefers-reduced-motion 對於要求減少動效的使用者,瀏覽器並不會統一把 behavior: "smooth" 降級——這得你自己來:

const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({ behavior: reduce ? "auto" : "smooth" });

寫一次很容易,在另外十一處滾動的地方忘掉也很容易。

useScrollIntoView

useScrollIntoView 自己在 requestAnimationFrame 上跑動畫,這四件事就都換回來了:

npm install @reactuses/core
import { useRef } from "react";
import { useScrollIntoView } from "@reactuses/core";

function Article() {
  const targetRef = useRef<HTMLParagraphElement>(null);
  const { scrollIntoView, cancel } = useScrollIntoView(targetRef, {
    duration: 600,
    offset: 80,
    onScrollFinish: () => targetRef.current?.focus(),
  });

  return (
    <>
      <button onClick={() => scrollIntoView({ alignment: "center" })}>跳到詳情</button>
      <div style={{ height: "150vh" }} />
      <p ref={targetRef} tabIndex={-1}>詳情</p>
    </>
  );
}

useScrollIntoView(target, options?, scrollContainer?) 返回 { scrollIntoView, cancel }。它是 SSR 安全的——你不呼叫,它就不碰 DOM——而且 target 可以是 ref、元素,或者一個 getter 函式,你手上有什麼都能用。

配置項全是可選的:

參數預設值說明
duration1250毫秒。0 表示瞬間跳過去。
easingeaseInOutQuad任意 0…1 上的 (t: number) => number
axis"y"橫向滾動容器用 "x"。一個 hook 一個軸。
offset0離邊緣的額外距離——就是固定頭部的餘量。
cancelabletrue滾輪或觸控會中止動畫。
isListfalse目標已在視野內就不滾。
onScrollFinish動畫停下時觸發。

對齊方式放在呼叫上而不是配置裡,因為它通常每次都不同:scrollIntoView({ alignment: "start" | "center" | "end" })

cancelable 是你真能感覺到的那個

cancelable: true(預設)時,hook 會監聽 wheeltouchmove,一有動靜就把動畫停在當下。使用者飛到一半伸手去夠捲軸,頁面就……讓他滾。對比一下 behavior: "smooth",它能跟使用者較勁整整一秒。

你也可以自己停——比如觸發滾動的那個彈窗被關掉了:

const { scrollIntoView, cancel } = useScrollIntoView(targetRef);
useEffect(() => cancel, [cancel]); // 卸載時也會自動取消

減少動效已經處理好了

hook 內部通過 useReducedMotionprefers-reduced-motion。當用戶要求減少動效時,緩動直接坍縮到終值,滾動變成瞬間跳轉——同樣的目的地、同樣會觸發 onScrollFinish,只是沒有動畫。這個分支不用你寫。

在容器裡滾,以及橫著滾

想動某個具體元素的滾動位置而不是整頁時,把滾動容器作為第三個參數傳進去:

const listRef = useRef<HTMLDivElement>(null);
const itemRef = useRef<HTMLLIElement>(null);

const { scrollIntoView } = useScrollIntoView(itemRef, { isList: true }, listRef);

不傳第三個參數時,hook 會從目標往上找,挑第一個計算樣式裡 overflow-x/overflow-yautoscroll 的祖先,找不到就回退到整頁。這個自動探測大多數時候方便且正確;你確定容器是誰的時候就顯式傳。

輪播就換個軸:

const { scrollIntoView } = useScrollIntoView(slideRef, { axis: "x", duration: 400 }, trackRef);
scrollIntoView({ alignment: "center" });

滾動到第一個校驗失敗的欄位

開頭那個場景,每塊拼圖都放對位置:

function CheckoutForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const firstErrorRef = useRef<HTMLDivElement>(null);

  const { scrollIntoView } = useScrollIntoView(firstErrorRef, {
    offset: 96,          // 讓開固定頭部
    duration: 500,
    onScrollFinish: () => firstErrorRef.current?.querySelector("input")?.focus(),
  });

  function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const next = validate(values);
    setErrors(next);
    if (Object.keys(next).length > 0) scrollIntoView({ alignment: "start" });
  }

  const firstErrorField = Object.keys(errors)[0];

  return (
    <form onSubmit={onSubmit}>
      {FIELDS.map(f => (
        <Field key={f.name} ref={f.name === firstErrorField ? firstErrorRef : undefined} {...f} />
      ))}
    </form>
  );
}

因為 hook 是在你呼叫 scrollIntoView 的那一刻才去解析目標、而不是在渲染時解析,所以哪怕 firstErrorRef 是被 setErrors 觸發的那次渲染才掛上的,在同一個處理函數里呼叫它照樣有效。不需要 flushSync,也不需要 effect。把焦點轉移放在 onScrollFinish 裡而不是立刻執行,能讓讀屏使用者和視力正常的使用者同時抵達。

值得知道的坑

  • offsetalignment: "center" 不生效。 它是從最近的邊緣量起的餘量,所以隻影響 "start""end"。想把某個東西居中又要避開固定頭部,要麼用 "start" 配 offset,要麼接受居中。這一條如果你想當然,它會悄無聲息地什麼都不做。
  • 別和 scroll-behavior: smooth 一起用。 hook 是靠每幀賦值 scrollTop/scrollLeft 來做動畫的。如果 CSS 也宣告這個容器要平滑滾動,瀏覽器會試圖給這 ~60 次賦值每一次都做動畫,結果是一坨卡頓。每個容器二選一:要麼 CSS 平滑滾動,要麼這個 hook。
  • 一個 hook 只管一個軸。 axis"x""y",不能都要。需要斜向移動的網格就用兩個 hook,或者用原生呼叫。
  • 自動探測到的滾動父容器會按元素快取。 某個節點的第一次查詢結果會被記住。如果你的佈局在執行時切換祖先的 overflow——比如面板展開後才變得可滾動——那就把容器作為第三個參數傳進去,別靠探測。
  • cancelable 管滾輪和觸控,不管鍵盤。 Page Down 和拖捲軸不會中止動畫。它覆蓋的是常見情況,不是全部情況;真在意的話自己在 keydown 裡調 cancel()
  • isList 是有方向的。 isList: true 時,hook 只在目標位於容器alignment 決定的那一側之外時才移動——已經可見的目標壓根不會產生滾動。這正是它的用意(防止鍵盤導航的列表每敲一次鍵就抖一下),但也意味著 isList: true 配錯 alignment 會讓人覺得 hook 在無視你。
  • duration: 0 是瞬間跳轉,不是什麼都不做。 想遵循你自己那個”關閉動畫”設定時很好用,不用去分支判斷該調哪個函式。

什麼時候別用這個 hook

原生 scrollIntoView 是正解的場合,比你以為的多:

  • 靜態錨點或目錄連結el.scrollIntoView({ behavior: "smooth" })scroll-margin-top。沒有依賴,沒有動畫迴圈。
  • 列表框的鍵盤導航block: "nearest" 原生就是最小移動的行為,而且那個場景本來就該是瞬間的手感。
  • 需要同時對齊兩個軸 → 原生呼叫同時接 blockinline
  • 你要滾到某個位置而不是某個元素window.scrollTo / el.scrollTo,或者用 useScroll 讀取和響應滾動位置。
  • 你想知道什麼在視野裡,而不是移動過去useIntersectionObserver,目錄裡高亮當前章節也是靠它。
  • 你想徹底禁止頁面滾動(彈窗開啟時)→ useScrollLock

要點

  • 基礎寫法就三行:給元素上 useRef,處理函數里 ref.current?.scrollIntoView({ behavior: "smooth" }),加 ?. 是因為提交之前 ref 是 null。記住 block: "nearest"——你用得最多的就是它。
  • 固定頭部遮擋用 CSS 的 scroll-margin-top 解決,別拿 getBoundingClientRect() 減一個寫死的像素值。前者扛得住響應式頭部,還順手修好 :target 錨點。
  • 要滾動到剛渲染出來的東西,就在按變化取依賴的 effect 裡滾,或者用回呼 ref。flushSync 能用,但要放棄批處理——留著當逃生艙。
  • 原生平滑滾動沒有時長控制、沒有靠譜的完成事件、不能取消、不處理 prefers-reduced-motion。這四條你都不在乎,就別加依賴。
  • useScrollIntoView 補的恰好就是這幾個缺口——可配的 duration/easingonScrollFinish、滾輪和觸控即時取消、自動的減少動效回退,外加 offset、橫向 axis、顯式滾動容器,以及讓列表導航不抖的 isList。它在呼叫時才解析目標,所以能和渲染出它的那次 setState 待在同一個處理函數里。

useScrollIntoViewuseScrolluseScrollLock,以及另外 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——裝一次,可 tree-shake,沒有需要你伺候的依賴。

npm install @reactuses/core