Next.js 如何实现导航时的过渡动画?(使用 Framer Motion)

1,942 阅读9分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

Framer Motion 是 React 领域常用的动画库,目前 GitHub 22k Stars,Npm 周下载量 300W,算是 React 领域做动画主流的技术选型。Next.js 项目使用 Framer Motion 做动画也是常见的选择。

本篇我们将使用 Framer Motion 实现导航时的页面过渡动画。浏览器效果如下:

next-transition-5.gif

也可以实现带退场的过渡动画效果:

next-transition-9.gif

Framer Motion 不止用作过渡动画,也能实现常见的动画效果。比如这是多个元素进入视口时的入场动画,使用 Framer Motion 可以非常轻松的实现这个效果:

next-transition-7.gif

本篇就为大家详细讲解如何实现。为了方便演示,创建一个空的 Next.js 项目:

npx create-next-app@latest

注意勾选使用 TypeScript、Tailwind CSS、src 目录、App Router:

image.png

  1. 本篇已收录到掘金专栏《Next.js 开发指北》

  2. 系统学习 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>
  );
}

浏览器效果如下:

next-transition-1.gif

当点击链接导航的时候,因为使用了模板,组件销毁重建,执行 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,先声明一个对象,指定不同变体的名称和样式,这样就可以在 initialanimateexit 属性中使用这些变体名称,直接应用名称对应的样式组合。

因为 React 组件缺少当组件要被卸载时通知组件的生命周期方法,所以当要设置卸载动画时,要将其放入 <AnimatePresence>组件中,<AnimatePresence>会检测直接子级何时从 React 树中删除。

浏览器效果如下:

next-transition-4.gif

使用 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>
  );
}

浏览器动画效果如下:

next-transition-8.gif

其实文字本身的动画比较简单,就是从非透明变透明,但加上延迟和重复,就形成了一种奇特的动画效果。

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>
    </>
  );
}

此时浏览器效果如下:

next-transition-7.gif

是不是很神奇?原本可能会很复杂的代码,结果这么简单就实现了?那就让我们来细细看看它是怎么实现的!

核心的代码在这里:

<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.initialwhileInView 设置元素进入视口时的动画效果,为 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>
  );
}

浏览器效果如下:

next-transition-3.gif

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>
  );
}

此时浏览器效果如下:

next-transition-5.gif

我们实现了导航时不同的过渡动画。当点击 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>
  );
}

浏览器效果与之前的效果相同:

next-transition-6.gif

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-transition-9.gif

最后

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…

通过文字建立交流本身就是一种缘分,欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。