feat: add page transition animations and loading indicators

- Add nextjs-toploader for instant top progress bar on navigation
- Add next-view-transitions for View Transitions API page transitions
- Enhance template.tsx page enter animation (0.45s, scale effect)
- Replace next/link with next-view-transitions Link for smooth transitions
- Add prefers-reduced-motion support for accessibility

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-13 23:07:51 +08:00
parent a4e88fa506
commit 7d85446ac5
16 changed files with 110 additions and 43 deletions

View File

@@ -1,4 +1,4 @@
import Link from 'next/link';
import { Link } from 'next-view-transitions';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';

View File

@@ -6,6 +6,8 @@ import { ThemeProvider } from 'next-themes';
import { Playfair_Display, LXGW_WenKai_TC } from 'next/font/google';
import { JsonLd } from '@/components/json-ld';
import { WebVitals } from '@/components/web-vitals';
import { ViewTransitions } from 'next-view-transitions';
import NextTopLoader from 'nextjs-toploader';
const playfair = Playfair_Display({
subsets: ['latin'],
@@ -98,19 +100,27 @@ export default function RootLayout({
};
return (
<html lang={siteConfig.defaultLocale} suppressHydrationWarning className={`${playfair.variable} ${lxgwWenKai.variable}`}>
<head>
{/* Preconnect to Google Fonts for faster font loading */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body>
<JsonLd data={websiteSchema} />
<JsonLd data={organizationSchema} />
<style
// Set CSS variables for accent colors (light + dark variants)
dangerouslySetInnerHTML={{
__html: `
<ViewTransitions>
<html lang={siteConfig.defaultLocale} suppressHydrationWarning className={`${playfair.variable} ${lxgwWenKai.variable}`}>
<head>
{/* Preconnect to Google Fonts for faster font loading */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body>
<NextTopLoader
color={theme.accent}
height={3}
showSpinner={false}
speed={200}
shadow={`0 0 10px ${theme.accent}, 0 0 5px ${theme.accent}`}
/>
<JsonLd data={websiteSchema} />
<JsonLd data={organizationSchema} />
<style
// Set CSS variables for accent colors (light + dark variants)
dangerouslySetInnerHTML={{
__html: `
:root {
--color-accent: ${theme.accent};
--color-accent-soft: ${theme.accentSoft};
@@ -118,13 +128,14 @@ export default function RootLayout({
--color-accent-text-dark: ${theme.accentTextDark};
}
`
}}
/>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<LayoutShell>{children}</LayoutShell>
</ThemeProvider>
<WebVitals />
</body>
</html>
}}
/>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<LayoutShell>{children}</LayoutShell>
</ThemeProvider>
<WebVitals />
</body>
</html>
</ViewTransitions>
);
}

View File

@@ -1,4 +1,4 @@
import Link from 'next/link';
import { Link } from 'next-view-transitions';
import { getAllPostsSorted } from '@/lib/posts';
import { siteConfig } from '@/lib/config';
import { PostListItem } from '@/components/post-list-item';

View File

@@ -1,4 +1,4 @@
import Link from 'next/link';
import { Link } from 'next-view-transitions';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';

View File

@@ -1,4 +1,4 @@
import Link from 'next/link';
import { Link } from 'next-view-transitions';
import type { Metadata } from 'next';
import { FiTag, FiTrendingUp } from 'react-icons/fi';
import { getAllTagsWithCount } from '@/lib/posts';

View File

@@ -1,20 +1,35 @@
'use client';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
export default function Template({ children }: { children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(true);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mq.matches);
const handler = () => setPrefersReducedMotion(mq.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (prefersReducedMotion) {
container.style.animation = 'none';
container.style.opacity = '1';
container.style.transform = 'none';
return;
}
// Trigger animation on mount
container.style.animation = 'none';
// Force reflow
void container.offsetHeight;
container.style.animation = 'pageEnter 0.3s cubic-bezier(0.32, 0.72, 0, 1) forwards';
}, [children]);
container.style.animation = 'pageEnter 0.45s cubic-bezier(0.32, 0.72, 0, 1) forwards';
}, [children, prefersReducedMotion]);
return (
<div ref={containerRef} className="page-transition">