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 個都採用,在支援 View Transition API 的裝置使用 View Transition API,而如果不支援,就退回使用 JavaScript 做頁面切換。
接著就來看看我是如何使用這 2 者的吧!
使用 next-transition-router
首先先處理 JS 版本的頁面轉換,如果是 Next.js 專案要使用 Page Transition,最快速的方式就是使用這個 Library:next-transition-router:
1. 安裝 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>
);
}使用 next-view-transitions 做加強
前面使用 next-transition-router 時,頁面會先播放離場動畫,等路由完成後再播放入場動畫,這種方式已經可以處理大部分的頁面切換,但若要像前面所說,想要同時保留新舊頁面,再用推移、淡入淡出等效果處理兩個畫面,就必須使用 View Transition API,如下影片:
我們可以使用 next-view-transitions 這個套件,他把瀏覽器原生的 View Transition API 整合到 Next.js App Router 中。
next-view-transitions 安裝與基本使用
先安裝套件:
pnpm add next-view-transitions接著在專案的根 layout 使用 ViewTransitions 包住整個網站:
import { ViewTransitions } from 'next-view-transitions';
export default function Layout({ children }) {
return <ViewTransitions>{children}</ViewTransitions>;
}如果要套用 View Transition 的頁面轉場,並須改用套件提供的 Link:
import { Link } from 'next-view-transitions';
<Link href="/about">About</Link>;套件會在連結點擊時呼叫 document.startViewTransition(),等待 Next.js 完成畫面更新後,再讓瀏覽器播放轉場。
瀏覽器的上一頁與下一頁操作,也會透過 popstate 走相同流程。
接著我們要新增一個 css file,因為 View Transition API 會建立一組 pseudo-element,我們可以在 CSS 裡控制舊畫面和新畫面的動畫:
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
::view-transition-old(page-content) {
animation: page-content-leave 1000ms ease-in-out both;
}
::view-transition-new(page-content) {
animation: page-content-enter 1000ms ease-in-out both;
}
@keyframes page-content-leave {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(-35%);
opacity: 0.2;
}
}
@keyframes page-content-enter {
from {
clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%);
}
to {
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
}
}如果要替特定區塊命名,則在前後畫面使用相同的 view-transition-name:
.view-transition-page {
view-transition-name: page-content;
}這樣只會針對 page-content 播放動畫,也能避免 root 的預設淡入淡出同時播放,這樣基本上就大功告成啦:
如何兼具兩個套件?
我的做法是讓兩個套件分工,當瀏覽器支援 View Transition API 時,使用 next-view-transitions 的原生轉場;不支援時,退回 next-transition-router 的 JavaScript 動畫。
做法是透過 "startViewTransition" in document 來判斷是否支援 Transition API,並且搭配 next-transition-router 的 auto props,他會在 true 時自動替換掉網站的連結,使連結支援 Page Transition,所以如果瀏覽器支援 View Transition API,就要把它關掉:
const [isViewTransitionSupport, setIsViewTransitionSupport] = useState(false);
useEffect(() => {
setIsViewTransitionSupport('startViewTransition' in document);
}, []);
<TransitionRouter auto={isViewTransitionSupport === false}>
{children}
</TransitionRouter>;另外我的專案是使用 CustomLink 元件,讓我能依照連結類型有不同的功能,比如跨頁連結使用 ViewTransitionLink,hash 連結、外部連結和目前所在頁面則使用一般的 NextLink:
const hrefPathname =
typeof href === 'string'
? href.split('#')[0]?.split('?')[0]
: href?.pathname?.split('#')[0]?.split('?')[0];
const isSamePath = hrefPathname === pathname;
const isHashLink = typeof href === 'string' && href.startsWith('#');
const LinkComponent =
isExternalHref(href) || isHashLink || isSamePath
? NextLink
: ViewTransitionLink;這樣在點擊指向目前頁面的首頁連結時,舊不會重新播放頁面轉場;文章目錄也只會更新 hash 和 scroll 位置。
Next.js 的 scroll 問題
接上 View Transition API 後,我遇到另一個 scroll 問題。
Next.js 會依照自己的導航流程處理 window 的 scroll,但轉場期間舊頁面和新頁面會同時存在,在切換頁面時,會看到舊頁面觸發了 Next 的 scroll 自動滾到最上面,畫面會明顯跳動:
我的解法是在路由內容外再包一層 wrapper,並限制它的高度搭配 overflow-y-hidden,因為 Next 的 Scroll 會啟用在 window scroll 上,但如果 scroll 的位置不是 window 而是一般的 <div> 就可避免這個問題:
<div id="site-scroll-container" className="max-h-screen overflow-y-hidden">
<div className="view-transition-page">{children}</div>
</div>瀏覽器上一頁和下一頁的 hash 問題
不過後來我用遇到另一個問題,我的文章目錄是使用 #heading-2 這種 hash 連結,點擊目錄時,URL 和畫面都會正常更新,但按瀏覽器上一頁或下一頁時,next-view-transitions 會把 hash 的變化當成頁面切換,因此當用戶點擊文章目錄時也會播放完整的頁面轉場。
後來去看 next-view-transition 的程式碼後發現,套件會對每次 popstate 呼叫 document.startViewTransition(),但 popstate 不只會在不同頁面之間觸發,使用者在同一頁切換 hash 時也會觸發。
所以我利用 pnpm patch 在套件的 onPopState 補上 pathname 判斷:
const onPopState = () => {
if (window.location.pathname === currentPathname.current) {
return;
}
// 其他程式碼 ...
};如此一來,從 /article#heading-2 到 /article#heading-3 時,只會跳到對應段落,不會播放頁面轉場。
接著,還要在 hashchange 事件裡手動處理文章目錄的 scroll 功能:
function handleHashChange() {
const nextHashId = getHashId();
if (!nextHashId) {
document.querySelector('main')?.scrollIntoView({
behavior: 'auto',
block: 'start',
});
return;
}
requestAnimationFrame(() => {
document.getElementById(nextHashId)?.scrollIntoView({
behavior: 'auto',
block: 'start',
});
});
}當網址沒有沒有 hash 的情況,比如從 ``/article#heading-2按上一頁回到/article 時,window.location.hash` 會變成空字串,此時就要回到畫面頂端。
使用 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 的實現邏輯
到這邊,我們已經完成了 2 種方式的 Page Transition 了,但我很好奇這個 next-transition-router 是如何實現這樣的效果的,所以我去看了他的原始程式碼,結果發現實現的方式也不難,我們自己手寫一個也不需要花太多時間。
這個套件核心只有 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 本身的生命週期就可以做到離場 → 換頁 → 入的順序,非常值得學習~!
