Page Transition(頁面轉場) 一直是很流行的網頁技術和效果,他指的是使用者從一個頁面切換到另一個頁面時,中間所發生的視覺變化。
為什麼需要頁面轉場 Page Transition?
當使用者從一個頁面前往另一個頁面時,如果畫面突然硬切、出現短暫白屏、載入過慢或讓使用者失去原本的操作脈絡,都可能讓人感到中斷和困惑,甚至提高離開的機率。
例如我用我自己的網站當作範例,原生網站在切換頁面時,網頁會直接跳轉,造成使用者體驗上的割裂感。
而頁面轉場 Page Transition 的目的就是要降低這種斷裂感,只要頁面之間的過渡做得好,就可以保留使用者的注意力、提供視覺的連續性和正面回饋來增強使用者的體驗,同時也能讓網頁更美觀和有趣來加強品牌形象。
如何使用 Page Transition
由於這個專案是使用 Next.js 開發,所以也會使用 Next.js 當作範例,一步一步帶你做出這種 Page Transition 的效果。
使用 Page Transition 有 2 種方法:
- 使用 View Transition API
- 使用 JavaScript 做頁面切換
View Transition API 是瀏覽器支援的,因此能做到更多種的效果,例如這個網站,就是把前後頁面的內容同時顯示在網頁上,並用推移的方式把舊的內容推走
但 View Transition API 的缺點是支援度還沒有很好,可以看到目前 FireFox 只有部分功能支援,一些比較舊的電腦也可能不知的這個功能
所以目前我的專案,是使用第 2 種方法製作 Page Transition,也就是用 JavaScript 製作,這樣就沒有支援度上的問題了!
1. 安裝 next-transition-router
如果 Next.js 專案要使用 Page Transition,最快速的方式就是使用這個 Library:next-transition-router:
pnpm add next-transition-router安裝後,我們新增一個 <TransitionRouterProvider/> 來使用套件提供的 <TransitionRouter />,他可以傳 auto prop 來自動監聽所有的 Next 連結:
"use client";
import { useRef } from "react";
import { animate } from "motion";
import { TransitionRouter } from "next-transition-router";
export function TransitionRouterProvider({
children,
}: {
children: React.ReactNode;
}) {
const wrapperRef = useRef<HTMLDivElement>(null!);
return (
<TransitionRouter
auto
leave={(next) => {
animate(
wrapperRef.current,
{ opacity: [1, 0] },
{ duration: 0.5, onComplete: next },
);
}}
enter={(next) => {
animate(
wrapperRef.current,
{ opacity: [0, 1] },
{ duration: 0.5, onComplete: next },
);
}}
>
<div ref={wrapperRef}>{children}</div>
</TransitionRouter>
);
}並且在 layout.tsx 中使用,基本就完成了:
<body>
<TransitionRouterProvider>{children}</TransitionRouterProvider>
</body>2. 增加更多 Page Transition 效果
如果你只需要淡入淡出的效果,這樣就已經做到了,但如果你想要再增添更多動畫,來增強網站的品牌感,可以參考我目前網站效果的思路:
- 離開時:頁面內容
opacity 1 → 0,並搭配translateY向上位移;深色 overlay 從畫面底部向上展開,覆蓋目前頁面。 - 進入時:路由切換後,overlay 移出畫面顯示新內容;新頁面內容
opacity 0 → 1,並由translateY位移狀態回到初始位置。
要實現這樣的 Page Transition,我們需要多一個元素來做遮罩,並且在 wrapperRef 增加 translate 的效果,
我們先新增一個元素遮罩,使用 clip-path:polygon(0%_100%,100%_100%,100%_100%,0%_100%) 來讓遮罩的初始樣式是隱藏的:
<div
ref={overlayRef}
className="fixed inset-0 top-0.75 z-router-overlay h-full w-full bg-primary [clip-path:polygon(0%_100%,100%_100%,100%_100%,0%_100%)]"
/>因為要控制這個遮罩,所以也要使用一個 overlayRef 來抓取 DOM 元素
const overlayRef = useRef<HTMLDivElement>(null!);接著處理 leave 和 enter 的邏輯:
<TransitionRouter
auto
leave={(next) => {
animate([
[
overlayRef.current,
{
clipPath: leaveClipPath,
},
{
duration: duration,
ease: VIEW_TRANSITION_EASING,
at: 0,
},
],
[
wrapperRef.current,
{
opacity: [1, 0],
y: leaveY,
},
{
y: {
duration: duration,
ease: VIEW_TRANSITION_EASING,
},
opacity: {
duration: duration,
ease: "linear",
},
at: 0,
},
],
]).then(next);
}}
enter={(next) => {
animate([
[
overlayRef.current,
{
clipPath: enterClipPath,
},
{
duration: duration,
ease: VIEW_TRANSITION_EASING,
at: 0,
},
],
[
wrapperRef.current,
{
opacity: [0, 1],
y: enterY,
},
{
y: {
duration: duration,
ease: VIEW_TRANSITION_EASING,
},
opacity: {
duration: duration,
ease: "linear",
},
at: 0,
},
],
]).then(next);
}}
>
<div
ref={overlayRef}
className="fixed inset-0 top-0.75 z-router-overlay h-full w-full bg-primary [clip-path:polygon(0%_100%,100%_100%,100%_100%,0%_100%)]"
/>
<div ref={wrapperRef}>{children}</div>
</TransitionRouter>最後,我使用 useReducedMotion 在希望不要太多動畫的裝置上,減少 Page Transition 的效果,例如取消 Overlay 和位移,以下是完整的程式碼:
const VIEW_TRANSITION_EASING = [0.9, 0, 0.1, 1] as const;
export function TransitionRouterProvider({
children,
footerData,
}: {
children: React.ReactNode;
footerData: FooterData;
}) {
const overlayRef = useRef<HTMLDivElement>(null!);
const wrapperRef = useRef<HTMLDivElement>(null!);
const shouldReduceMotion = useReducedMotion();
const leaveClipPath = shouldReduceMotion
? null
: [
"polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
"polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)",
];
const enterClipPath = shouldReduceMotion
? null
: [
"polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)",
"polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
];
const leaveY = shouldReduceMotion ? 0 : [0, -240];
const enterY = shouldReduceMotion ? 0 : [240, 0];
const duration = shouldReduceMotion ? 0.4 : 0.8;
return (
<TransitionRouter
auto
leave={(next) => {
animate([
[
overlayRef.current,
{
clipPath: leaveClipPath,
},
{
duration: duration,
ease: VIEW_TRANSITION_EASING,
at: 0,
},
],
[
wrapperRef.current,
{
opacity: [1, 0],
y: leaveY,
},
{
y: {
duration: duration,
ease: VIEW_TRANSITION_EASING,
},
opacity: {
duration: duration,
ease: "linear",
},
at: 0,
},
],
]).then(next);
}}
enter={(next) => {
animate([
[
overlayRef.current,
{
clipPath: enterClipPath,
},
{
duration: duration,
ease: VIEW_TRANSITION_EASING,
at: 0,
},
],
[
wrapperRef.current,
{
opacity: [0, 1],
y: enterY,
},
{
y: {
duration: duration,
ease: VIEW_TRANSITION_EASING,
},
opacity: {
duration: duration,
ease: "linear",
},
at: 0,
},
],
]).then(next);
}}
>
<div
ref={overlayRef}
className="fixed inset-0 top-0.75 z-router-overlay h-full w-full bg-primary [clip-path:polygon(0%_100%,100%_100%,100%_100%,0%_100%)]"
/>
<div ref={wrapperRef}>{children}</div>
</TransitionRouter>
);
}使用 Page Transition 要注意的事情
雖然 Page Transition 可以讓網站的切換更有記憶點,也能讓新舊頁面之間的關係更清楚。
不過,它本質上仍然是一段的動畫,如果時間太長、效果太多,或每次切換都過度強調,反而會讓使用者覺得網站變慢,甚至造成操作上的干擾。
以下是幾個在使用 Page Transition 時需要注意的重點。
1. 時間不要太長
大多數 UI 動畫的時間建議落在 100ms 到 500ms 之間,實際長度會取決於動畫的複雜度與元素移動距離。動畫太快,使用者可能看不清楚狀態變化;但動畫太慢,則會讓人覺得被迫等待。
Page Transition 屬於比較大範圍的畫面切換,因此可以比一般 micro interaction 稍微長一些,不過我會建議整體體感時間控制在 500ms 到 1600ms 左右;如果超過 1600ms,使用者就會開始感覺到延遲。
2. 動畫要有方向感
動畫的目的除了裝飾以外,重點應該是要幫助使用者理解狀態、路徑與內容關係,而不只是讓畫面看起來比較炫。所以 page transition 要注意:
- 從哪裡離開
- 從哪裡進入
- 新舊頁面之間的層級關係和方向關係
這也是我在網站中讓舊頁面往上離開、新頁面由下往上進入的原因。這樣的動線會讓切換更像是往下一個內容段落前進的感覺,而不是單純把兩個頁面硬接在一起。
3. 需要注意效能
Page Transition 通常會影響整個畫面,所以效能比一般小型互動更重要。
建議在動畫上,優先考慮 opacity、transform 等效果,如果要加上 blur 、clippath、 box-shadow 或 width、 height 等 layout 變化,就需要謹慎一點,因為可能讓在切換頁面時,出現卡頓的效果。
4. 支援 reduced motion
prefers-reduced-motion 可以偵測使用者是否在系統層級要求減少動畫。對於不想要動畫的使用者,應該減少或避免不必要的動畫。
我的做法是減少動畫效果,而沒有完全取消。
next-transition-router 的實現邏輯
到這邊,我們已經完成了 Page Transition 了,但我很好奇這個 Library 是如何實現這樣的效果的,所以我去看了他的原始程式碼,結果發現實現的方式也不難,我們自己手寫一個也不需要花太多時間。
這個套件核心只有 8 KB 左右,本質上是透過**控制反轉(Inversion of Control)**與 React Hook 生命週期清理機制,來達到以下流程:
- 離場動畫
- 路由更新
- 入場動畫
第一步:攔截導航行為
要實現轉場動畫,第一步要先攔截所有觸發頁面跳轉的行為,阻止瀏覽器與 Next.js 預設立刻換頁的動作。
這個庫提供了自定義的 <Link> 元件來手動攔截。但如果專案規模很大,我們不想一個一個去替換原生的 Next.js Link,最方便的做法是直接使用 Provider 的 auto={true} 功能。
auto 模式:全域事件代理
因為 Next.js 的 <Link> 最終在 DOM 中都是帶有 href 的 <a> 標籤,
所以開啟 auto 後,Provider 會用 delegate-it 在 document 全域監聽所有 a[href] 的 click 事件,符合條件就用 event.preventDefault() 阻止原生換頁並接管導航,不需要手動替換每個 Link 的 import。
useEffect(() => {
if (!auto) return;
const controller = new AbortController();
delegate("a[href]", "click", handleClick, { signal: controller.signal });
return () => controller.abort();
}, [auto, handleClick]);安全過濾:shouldLinkTriggerTransition
但我們不能盲目攔截所有連結。
如果使用者按住 Command 或 Ctrl 鍵(想在新分頁開啟)、連結標記為 target="_blank" 或 download,或是連到外部網站,都應該讓瀏覽器執行原生動作。
所以這個套件會使用 shouldLinkTriggerTransition 先做判斷
export function shouldLinkTriggerTransition(
link: HTMLAnchorElement,
event: any,
): boolean {
return (
link.target !== "_blank" &&
link.origin === window.location.origin &&
link.rel !== "external" &&
!link.download &&
!isModifiedEvent(event) &&
!event.defaultPrevented
);
}useTransitionRouter:重構程式化導航
除了點擊連結外,使用者也可能用 router.push('/about') 來切換路由。
所以如果要使用這種方式,必須使用套件提供的 useTransitionRouter ,他會重寫原生的 push 與 replace,統一路由的行為。
export function useTransitionRouter() {
const router = useRouter();
const pathname = usePathname();
const { navigate } = useTransitionState();
const push = useCallback(
(href: string, options?: NavigateOptions) => {
navigate(href, pathname, "push", options);
},
[pathname, navigate],
);
// ...
}第二步:狀態機與動畫生命週期核心
攔截完導航後,需要一個全域狀態機來協調「離場動畫 → 路由跳轉 → 入場動畫」的順序。
這個套件設計了三個狀態:"none"(靜止)、"leaving"(離場中)、"entering"(入場中)。
navigate:延遲路由跳轉,呼叫離場動畫
當 navigate 被呼叫時,並不會直接換頁,而是把真正的路由切換包成 next() 回調,傳給開發者定義的 leave。
const next = () => router[method](href, options);
// ...
setStage("leaving");
leaveRef.current = await leave(next, pathname, href);頁面狀態設為 "leaving" 後,離場動畫開始播放;動畫結束時,開發者手動呼叫 next(),Next.js 才真正換頁。
React effect cleanup:自動偵測路由完成
這是整個套件最精妙的設計在於 pathname 改變(代表 Next.js 換頁完成)時,React 在執行新 effect 前,會先執行上一次 effect 的 cleanup。
這個 cleanup 讀到的舊 stage 是 "leaving",就在這個「新頁面 DOM 掛載、舊 effect 卸載」的瞬間,自動將狀態切換到 "entering"。
useEffect(() => {
return () => {
if (stage === "leaving") {
setStage("entering");
}
};
}, [stage, pathname]);入場動畫與狀態歸零
stage 切換到 "entering" 時,會觸發一個 effect 執行開發者傳入的 enter 入場動畫。
傳給 enter 的參數是一個把 stage 重設為 "none" 的回調,當動畫結束呼叫它,狀態完整歸零。
useEffect(() => {
if (stage === "entering") {
const runEnter = async () => {
enterRef.current = await Promise.resolve(enter(() => setStage("none")));
};
runEnter();
}
}, [stage, enter]);總結
Page Transition 做得好,對於使用者來說是大大的加分,而實現方式其實也不會太過困難,使用 next-transition-router 就能做到。
next-transition-router 的本身其實不複雜,核心就是兩件事:
- 攔截導航,把
router.push包成next()傳給使用者,讓動畫決定換頁時機 - 靠
useEffectcleanup 偵測換頁完成,在pathname改變的瞬間自動切換狀態,銜接入場動畫
完整流程圖
用流程圖來表示就是:
- 使用
delegate-it監聽 Link 點擊事件(如果使用auto={true}) - 使用者點擊連結或呼叫
router.push()觸發 navigate() 函式 - 符合轉場條件?(同源、非 hash、非同頁)
setStage("leaving"),呼叫leave(next, from, to)函式- 播放離場動畫,動畫結束,開發者呼叫 next()
router.push()執行,Next.js 開始換頁pathname改變useEffect([stage, pathname])cleanup 執行,舊 stage === "leaving" →setStage("entering")useEffect([stage, enter])觸發,呼叫enter(() => setStage("none"))- 播放入場動畫
- 動畫結束,開發者呼叫回調
next()執行setStage("none")
整個流程不依賴任何動畫庫,只借助 React 本身的生命週期就可以做到離場 → 換頁 → 入的順序,非常值得學習~!
