本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
Framer Motion 是 React 领域常用的动画库,目前 GitHub 22k Stars,Npm 周下载量 300W,算是 React 领域做动画主流的技术选型。Next.js 项目使用 Framer Motion 做动画也是常见的选择。
本篇我们将使用 Framer Motion 实现导航时的页面过渡动画。浏览器效果如下:
也可以实现带退场的过渡动画效果:
Framer Motion 不止用作过渡动画,也能实现常见的动画效果。比如这是多个元素进入视口时的入场动画,使用 Framer Motion 可以非常轻松的实现这个效果:
本篇就为大家详细讲解如何实现。为了方便演示,创建一个空的 Next.js 项目:
npx create-next-app@latest
注意勾选使用 TypeScript、Tailwind CSS、src 目录、App Router:
本篇已收录到掘金专栏《Next.js 开发指北》
系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!
欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。
1. Template.js + 动态类名
如果只是简单的页面切换,其实我们在《Next.js v14 的模板(template.js)到底有啥用?》实现过类似的效果。我们以此为例,来看下传统过渡动画的实现方式。
新建 src/app/animate/layout.tsx
,代码如下:
import Link from "next/link";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="p-5">
<nav className="flex items-center justify-center gap-10 text-blue-600">
<Link href="/animate/about">About</Link>
<Link href="/animate/settings">Settings</Link>
</nav>
{children}
</div>
);
}
新建 src/app/animate/template.tsx
,代码如下:
"use client";
import { useState, useEffect } from "react";
export default function Template({ children }: { children: React.ReactNode }) {
const [animation, setAnimation] = useState("fadeOut");
useEffect(() => {
setAnimation("fadeIn");
}, []);
return <div className={`section ${animation}`}>{children}</div>;
}
在 Next.js 中,template.jsx
的特点就在于状态不会维持,所以模板会在导航的时候为每个子级创建一个新实例。这就意味着当用户在共享一个模板的路由间导航的时候,将挂载组件的新实例,DOM 元素会重新创建。
这样做有好处也有坏处。好处就在于做动画会比较方便,就是因为每次 DOM 都会销毁重建,所以我们的 setAnimation
才会在导航的时候每次都执行,从而实现每次导航都有动画。坏处在于销毁重建有一定的性能损失。
在 src/app/globals.css
添加动画样式代码如下:
.section {
transition: 2s;
}
.fadeIn {
opacity: 1;
}
.fadeOut {
opacity: 0;
}
新建 src/app/animate/about/page.tsx
,代码如下:
export default function Page() {
return (
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, About!
</div>
);
}
新建 src/app/animate/settings/page.tsx
,代码如下:
export default function Page() {
return (
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, Settings!
</div>
);
}
浏览器效果如下:
当点击链接导航的时候,因为使用了模板,组件销毁重建,执行 useEffect
,添加动画类,于是有了入场动画效果。
2. Framer Motion 介绍
Framer Motion 是一个简单但功能强大的 React 动画库。我们就以一些动画效果为例,为大家讲解如何使用。
2.1. 进入和退出动画
安装 Framer Motion:
npm install framer-motion
新建 src/app/motion/page.tsx
,代码如下:
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
export default function App() {
const [visible, setVisible] = useState(true);
const variants = {
hidden: { opacity: 0, y: 200 },
enter: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 0 },
};
return (
<>
<label>
<input
className="mx-auto mt-10"
type="checkbox"
checked={visible}
onChange={() => {
setVisible(!visible);
}}
/>
显示
</label>
<AnimatePresence>
{visible && (
<motion.div
className="w-10 h-10 bg-red-500 mx-auto mt-20"
initial="hidden"
animate="enter"
exit="exit"
variants={variants}
transition={{ duration: 0.5 }}
/>
)}
</AnimatePresence>
</>
);
}
这段代码是一个典型的动画示例。通过 motion.xxx
创建一个 Motion 动画组件。initial
设置组件的初始状态,animate
设置进入动画,exit
设置退出动画,transition
设置具体动画的时长等。
为了维护方便,我们使用了 variants
,先声明一个对象,指定不同变体的名称和样式,这样就可以在 initial
、animate
、exit
属性中使用这些变体名称,直接应用名称对应的样式组合。
因为 React 组件缺少当组件要被卸载时通知组件的生命周期方法,所以当要设置卸载动画时,要将其放入 <AnimatePresence>
组件中,<AnimatePresence>
会检测直接子级何时从 React 树中删除。
浏览器效果如下:
使用 Framer Motion 后,不需要再写各种类名和动态类名判断,开发效率直接提升。
2.2. 文字动画效果
我们再实现一个文字动画效果练练手。新建 src/app/text/page.tsx
,代码如下:
"use client";
import { motion } from "framer-motion";
const text = "Hello World!";
export default function App() {
return (
<div className="text-[50px] my-10 text-center">
{text.split("").map((letter, index) => (
<motion.span
key={index}
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{
duration: 3,
repeat: Infinity,
delay: index * 0.1,
}}
>
{letter}
</motion.span>
))}
</div>
);
}
浏览器动画效果如下:
其实文字本身的动画比较简单,就是从非透明变透明,但加上延迟和重复,就形成了一种奇特的动画效果。
2.3. 进入视口动画
当然 Framer Motion 能够实现的效果远不止如此,我们再实现一个元素进入视口时的动画效果。
新建 src/app/scroll/page.tsx
,代码如下:
"use client";
import { motion } from "framer-motion";
const data = [
"React",
"Vue",
"Angular",
"Svelte",
"Solid.js",
"Preact",
"Qwik",
"HTMX",
"Stencil",
"Lit",
"Alpine.js",
"Astro",
"Ember",
"Next.js",
"Remix",
"Elm",
];
const fadeInVariants = {
initial: {
opacity: 0,
y: 100,
},
animate: (index: number) => ({
opacity: 1,
y: 0,
transition: {
delay: 0.05 * index,
},
}),
};
export default function App() {
return (
<>
<div className="w-full h-[1000px] bg-red-100"></div>
<ul className="flex flex-wrap justify-center gap-2 text-gray-500 my-4">
{data.map((item, index) => {
return (
<motion.li
key={index}
variants={fadeInVariants}
initial="initial"
whileInView="animate"
className="border border-black rounded-xl px-4 py-2"
custom={index}
>
{item}
</motion.li>
);
})}
</ul>
<div className="w-full h-[1000px] bg-red-100"></div>
</>
);
}
此时浏览器效果如下:
是不是很神奇?原本可能会很复杂的代码,结果这么简单就实现了?那就让我们来细细看看它是怎么实现的!
核心的代码在这里:
<ul className="flex flex-wrap justify-center gap-2 text-gray-500 my-4">
{data.map((item, index) => {
return (
<motion.li
key={index}
variants={fadeInVariants}
initial="initial"
whileInView="animate"
className="border border-black rounded-xl px-4 py-2"
custom={index}
>
{item}
</motion.li>
);
})}
</ul>
我们将原本的 <li>
组件改成了 <motion.li>
动画组件,因为使用了变体(variants),所以元素初始样式(initial)
为 fadeInVariants.initial
。whileInView
设置元素进入视口时的动画效果,为 fadeInVariants.animate
。为了实现不同 <li>
元素相继进入的动画效果,我们使用了 custom
属性,表示自定义数据,会传给变体,用于动态判断使用的样式:
const fadeInVariants = {
initial: {
opacity: 0,
y: 100,
},
animate: (index: number) => ({
opacity: 1,
y: 0,
transition: {
delay: 0.05 * index,
},
}),
};
animate
函数的第一个参数 index
就是我们自定义传入的 custom
的值,我们用于设置动画的 delay
属性,这样就会有前后进入的动画效果。
3. Framer Motion 使用
我们如何使用 Framer Motion 实现导航时的过渡动画效果呢?
3.1. 页面过渡效果
新建 src/app/animate2/layout.tsx
,代码如下:
import Link from "next/link";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="p-5">
<nav className="flex items-center justify-center gap-10 text-blue-600">
<Link href="/animate2/about">About</Link>
<Link href="/animate2/settings">Settings</Link>
</nav>
{children}
</div>
);
}
新建 src/app/animate2/TransitionUp.tsx
,代码如下:
"use client";
import { motion } from "framer-motion";
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function Transition({ children }: Props) {
const variants = {
hidden: { opacity: 0, y: 200 },
enter: { opacity: 1, y: 0 },
};
return (
<motion.main
data-scroll
className="mb-auto"
initial="hidden"
animate="enter"
variants={variants}
transition={{ duration: 0.5 }}
>
{children}
</motion.main>
);
}
我们声明了一个 <Transition>
组件,它会有一个进入的动画效果。我们将页面组件使用该组件包裹即可。
新建 src/app/animate2/about/page.tsx
,代码如下:
import TransitionUp from "@/app/animate2/TransitionUp";
export default function Page() {
return (
<TransitionUp>
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, About!
</div>
</TransitionUp>
);
}
新建 src/app/animate2/settings/page.tsx
,代码如下:
import TransitionUp from "@/app/animate2/TransitionUp";
export default function Page() {
return (
<TransitionUp>
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, About!
</div>
</TransitionUp>
);
}
浏览器效果如下:
3.2. 不同的页面过渡效果
使用这种方式的好处在于我们还可以声明其他的动画效果,比如新建 src/app/animate2/TransitionDown.tsx
,代码如下:
"use client";
import { motion } from "framer-motion";
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function Transition({ children }: Props) {
const variants = {
hidden: { opacity: 0, y: -200 },
enter: { opacity: 1, y: 0 },
};
return (
<motion.main
data-scroll
className="mb-auto"
initial="hidden"
animate="enter"
variants={variants}
transition={{ duration: 0.5 }}
>
{children}
</motion.main>
);
}
修改 src/app/animate2/settings/page.tsx
,代码如下:
import TransitionDown from "@/app/animate2/TransitionDown";
export default function Page() {
return (
<TransitionDown>
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, Settings!
</div>
</TransitionDown>
);
}
此时浏览器效果如下:
我们实现了导航时不同的过渡动画。当点击 About 的时候,元素从下往上出现。当点击 Settings 的时候,元素从上往下出现。
3.3. 使用 template.js
如果过渡时的动画效果是统一的,也可以借助 template.js 实现,这样就可以避免每个需要动画的组件都放到自定义的 <Transition>
动画组件下。
新建 src/app/animate3/layout.tsx
,代码如下:
import Link from "next/link";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="p-5">
<nav className="flex items-center justify-center gap-10 text-blue-600">
<Link href="/animate3/about">About</Link>
<Link href="/animate3/settings">Settings</Link>
</nav>
{children}
</div>
);
}
新建 nsrc/app/animate3/template.tsx
,代码如下:
"use client";
import { motion } from "framer-motion";
export default function Template({ children }: { children: React.ReactNode }) {
const variants = {
hidden: { opacity: 0, y: 200 },
enter: { opacity: 1, y: 0 },
};
return (
<motion.main
data-scroll
className="mb-auto"
initial="hidden"
animate="enter"
variants={variants}
transition={{ duration: 0.5 }}
>
{children}
</motion.main>
);
}
新建 src/app/animate3/about/page.tsx
,代码如下:
export default function Page() {
return (
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, About!
</div>
);
}
新建 src/app/animate3/settings/page.tsx
,代码如下:
export default function Page() {
return (
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, Settings!
</div>
);
}
浏览器效果与之前的效果相同:
3.4. 退场动画效果
退场动画效果就稍微复杂了,Next.js 的 Discussions 里有讨论:《How to make Page Transitions In Nexjs14 App Dir in the layout.tsx》。目前没有看到非常好的方式,以下是一种能用的方式。
新建 src/app/animate5/layout.tsx
,代码如下:
import Link from "next/link";
import PageAnimatePresence from "./PageAnimatePresence";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="p-5">
<nav className="flex items-center justify-center gap-10 text-blue-600">
<Link href="/animate5/about">About</Link>
<Link href="/animate5/settings">Settings</Link>
</nav>
<PageAnimatePresence>{children}</PageAnimatePresence>
</div>
);
}
新建 src/app/animate5/PageAnimatePresence.tsx
,代码如下:
"use client";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useContext, useRef } from "react";
// 阻止页面立即打开,先让退场动画走完,再显示新的页面内容
function FrozenRouter(props: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext ?? {});
const frozen = useRef(context).current;
return (
<LayoutRouterContext.Provider value={frozen}>
{props.children}
</LayoutRouterContext.Provider>
);
}
const PageAnimatePresence = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname();
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial="initialState"
animate="animateState"
exit="exitState"
transition={{
duration: 0.75,
}}
variants={{
initialState: {
opacity: 0,
clipPath: "polygon(0 0, 100% 0, 100% 100%, 0% 100%)",
},
animateState: {
opacity: 1,
clipPath: "polygon(0 0, 100% 0, 100% 100%, 0% 100%)",
},
exitState: {
clipPath: "polygon(50% 0, 50% 0, 50% 100%, 50% 100%)",
},
}}
className="w-full min-h-screen"
>
<FrozenRouter>{children}</FrozenRouter>
</motion.div>
</AnimatePresence>
);
};
export default PageAnimatePresence;
退场动画第一个麻烦的地方在于监听退场,所以我们使用了 Framer Motion 的 <AnimatePresence>
组件,当直接子级从 React 树中移除的时候便会触发退场动画。
退场动画第二个麻烦的地方在于当你点击导航的时候,新路由的内容可能立刻就显示了出来,但此时退场动画还没有结束,此时效果就比较诡异。所以这段代码中有个 FrozenRouter 函数就是为了解决这个问题,然而你可以看到 FrozenRouter 函数的实现非常 hack,但目前尚未看到更好的解决方法。
新建 src/app/animate5/settings/page.tsx
,代码如下:
export default function Page() {
return (
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, Settings!
</div>
);
}
新建 src/app/animate5/about/page.tsx
,代码如下:
export default function Page() {
return (
<div className="h-60 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
Hello, About!
</div>
);
}
浏览器效果如下:
最后
Next.js 项目做动画使用 Framer Motion 是比较常见的选择。本篇我们介绍了 Framer Motion 的基本使用方式以及如何在 Next.js 中使用。
Next.js 系列
本篇已收录在掘金专栏 《Next.js 开发指北》,该系列一共 24 篇。
系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!
此外我还写过 JavaScript 系列、TypeScript 系列、React 系列、Next.js 系列、冴羽答读者问等 14 个系列文章, 全系列文章目录:github.com/mqyqingfeng…
通过文字建立交流本身就是一种缘分,欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。