Communitygithub.com

KilianBStar/motion-patterns

適用於 React / Next.js 的生產級動畫模式 — 按鈕、模態框、通知、交錯、頁面轉場、退出動畫、滾動與佈局 — 基於 motion-foundations 的令牌與彈簧。當你在 React 或 Next.js 中為特定 UI 元素(按鈕、模態框、通知、交錯、頁面轉場或滾動)製作動畫時使用。

motion-patterns 是什麼?

這項技能提供了一系列可直接複製貼上的動畫模式,專為 React 和 Next.js 中常見的 UI 動畫需求而設計。每個模式都基於 `motion-foundations` 的設計令牌和彈簧物理參數,確保了視覺上的一致性和效能。它涵蓋了按鈕回饋、交錯列表、模態框、通知堆疊、頁面轉場、滾動觸發、展開卡片和共享元素轉場等場景。技能的核心原則是強制使用預定義的令牌(避免內聯數值)、正確包裝 `AnimatePresence` 與 `key`、以及始終為條件式渲染定義 `exit` 動畫。此外,它還提供了清晰的決策指引和反模式列表,幫助開發者避免常見錯誤。該技能支援所有使用 `motion/react` 的 React 應用,特別針對 Next.js App Router 進行了優化,並且包含了可訪問性和 SSR 安全的考量。

相容平台~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/KilianBStar/mes-skills-/tree/HEAD/.claude/skills/motion-patterns

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

Motion Patterns

為最常見的 UI 動畫需求提供可複製貼上的模式。 此處的每個模式都基於 motion-foundations 的令牌和彈簧構建。 請勿在此定義新的持續時間或緩動值 — 請導入它們。

何時啟用

  • 為按鈕、卡片、模態框或通知添加動畫
  • 使用交錯效果建立列表項目的進場動畫
  • 在 Next.js App Router 中設定頁面轉場
  • 為條件式內容添加入場或出場動畫
  • 實現滾動觸發、滾動關聯進度條或固定故事章節
  • 建立展開卡片、手風琴或共享元素轉場

產出

此技能將產出:

  • 所有標準 UI 組件的可訪問、SSR 安全的動畫
  • 包裝於 AnimatePresence 中的條件式渲染,並具有正確的退出行為
  • 適用於 Next.js App Router 的頁面轉場包裝組件
  • 使用 useScroll + useTransform 的滾動觸發和滾動關聯模式
  • 用於展開和交叉淡化的佈局動畫模式(layout、layoutId)

原則

  • 每個模式都從 motion-foundations 導入。沒有原始數值。
  • 每個條件式渲染都包裝在帶有 key 的 AnimatePresence 中。
  • 退出動畫始終與進入動畫一起定義 — 絕不能事後添加。
  • layout 僅用於小幅的、孤立的移動。大型子樹使用明確的 transform。

規則

  1. 始終將條件式渲染包裝在帶有 key 的 AnimatePresence 中,並作用於直接子元素。沒有 key,退出動畫永遠不會觸發。
  2. 定義 initial + animate 時,始終同時定義 exit。沒有退出動畫的動畫是不完整的。
  3. 在頁面轉場中使用 mode="wait"。進入必須在退出完成後才能開始。
  4. 切勿對超過約 5 個子元素或嵌套較深的 DOM 使用 layout。改用明確的 x/y 變換。
  5. 交錯間隔必須保持在 0.05s 和 0.10s 之間。低於此值感覺機械化;高於此值感覺遲緩。
  6. 模態框必須始終包含: 焦點陷阱、按 Escape 鍵關閉、滾動鎖定、role="dialog"、aria-modal="true"。
  7. 滾動觸發使用 viewport={{ once: true }}。在滾出時重複觸發會分散注意力,而非提供資訊。
  8. 所有令牌值都從 motion-foundations 導入。沒有內聯數值。

決策指南

選擇正確的模式

情境模式
元素出現 / 消失AnimatePresence
列表項目按順序載入Stagger variants
在路由之間導航Page transition wrapper
元素在原地改變大小layout prop
相同元素在頁面上下文間移動layoutId
元素滾動到視野時出現whileInView
數值與滾動位置相關聯useScroll + useTransform

何時使用 mode="wait" 與 mode="sync"

模式使用時機
wait頁面轉場、內容切換(一次一個)
sync堆疊通知、列表項目(重疊沒問題)
popLayout從需要重新排版的列表中移除項目

核心概念

AnimatePresence 合約

三件事必須始終為真:

  1. AnimatePresence 包裝了條件式內容
  2. 直接子元素具有 key
  3. 子元素具有 exit 屬性

缺少任何一項,退出動畫都會靜默地失敗。

layout 與 layoutId

  • layout — 在原地對元素自身的大小/位置變化進行動畫處理
  • layoutId — 連結兩個獨立的元素,在渲染之間對它們進行交叉淡化

在展開容器內部的文字上使用 layout="position",以防止文字重排被動畫化。

程式碼範例

按鈕回饋

"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"

<motion.button
  whileHover={{ scale: motionTokens.scale.pop }}
  whileTap={{ scale: motionTokens.scale.press }}
  transition={springs.snappy}
/>

交錯列表

"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

const container = {
  hidden: {},
  visible: {
    transition: {
      staggerChildren: 0.08,   // 在 0.05–0.10 規則範圍內
      delayChildren: 0.1,
    },
  },
}

const item = {
  hidden:  { opacity: 0, y: motionTokens.distance.md },
  visible: { opacity: 1, y: 0, transition: springs.gentle },
}

<motion.ul variants={container} initial="hidden" animate="visible">
  {items.map((i) => (
    <motion.li key={i.id} variants={item} />
  ))}
</motion.ul>

模態框

"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

// 在調用處包裝:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>

export function Modal({ onClose }: { onClose: () => void }) {
  return (
    <>
      {/* 遮罩層 */}
      <motion.div
        className="fixed inset-0 bg-black/50"
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        onClick={onClose}
      />

      {/* 面板 — 可訪問性要求:焦點陷阱、Escape 關閉、
          滾動鎖定、role="dialog"、aria-modal="true" */}
      <motion.div
        role="dialog"
        aria-modal="true"
        className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
        initial={{
          opacity: 0,
          scale: motionTokens.scale.press,
          y: motionTokens.distance.sm,
        }}
        animate={{ opacity: 1, scale: 1, y: 0 }}
        exit={{
          opacity: 0,
          scale: motionTokens.scale.press,
          y: motionTokens.distance.sm,
        }}
        transition={springs.gentle}
      />
    </>
  )
}

通知堆疊

"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

<AnimatePresence mode="sync">
  {toasts.map((t) => (
    <motion.div
      key={t.id}
      layout
      initial={{
        opacity: 0,
        x: motionTokens.distance.xl,
        scale: motionTokens.scale.subtle,
      }}
      animate={{ opacity: 1, x: 0, scale: 1 }}
      exit={{
        opacity: 0,
        x: motionTokens.distance.xl,
        scale: motionTokens.scale.subtle,
      }}
      transition={springs.snappy}
    />
  ))}
</AnimatePresence>

頁面轉場 (Next.js App Router)

// components/page-transition.tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
import { motionTokens } from "@/lib/motion-tokens"

const variants = {
  initial: { opacity: 0, y: motionTokens.distance.sm },
  enter:   { opacity: 1, y: 0 },
  exit:    { opacity: 0, y: -motionTokens.distance.sm },
}

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname()
  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={pathname}
        variants={variants}
        initial="initial"
        animate="enter"
        exit="exit"
        transition={{
          duration: motionTokens.duration.normal,
          ease: motionTokens.easing.smooth,
        }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  )
}

滾動觸發

"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

<motion.div
  initial={{ opacity: 0, y: motionTokens.distance.lg }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, margin: "-80px" }}   // once: true — 規則 7
  transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>

滾動進度條

"use client"
import { motion, useScroll } from "motion/react"

export function ScrollProgress() {
  const { scrollYProgress } = useScroll()
  return (
    <motion.div
      className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
      style={{ scaleX: scrollYProgress }}
    />
  )
}

展開卡片

"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"

export function ExpandingCard({ title, body }: { title: string; body: string }) {
  const [expanded, setExpanded] = useState(false)

  return (
    <motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
      {/* layout="position" 防止文字重排被動畫化 */}
      <motion.h2 layout="position" className="font-semibold">
        {title}
      </motion.h2>

      <AnimatePresence>
        {expanded && (
          <motion.p
            key="body"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: motionTokens.duration.fast }}
          >
            {body}
          </motion.p>
        )}
      </AnimatePresence>
    </motion.div>
  )
}

共享元素交叉淡化

// 來源上下文
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />

// 目標上下文(相同的 layoutId — motion 會處理轉場)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />

手風琴

<motion.div
  initial={false}
  animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
  style={{ transformOrigin: "top", overflow: "hidden" }}
  transition={{
    duration: motionTokens.duration.normal,
    ease: motionTokens.easing.smooth,
  }}
> {children}
</motion.div>

端到端範例

一個在掛載時進入、處理條件式存在並尊重減少動畫設定的交錯列表 — 結合影音令牌、彈簧、AnimatePresence 以及來自 motion-foundations 的可訪問性鉤子:

"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"

const containerVariants = {
  hidden: {},
  visible: {
    transition: { staggerChildren: 0.08, delayChildren: 0.1 },
  },
}

function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
  const safe = useSafeMotion(motionTokens.distance.sm)
  return (
    <motion.li
      variants={{
        hidden:  safe.initial,
        visible: safe.animate,
      }}
      exit={safe.exit}
      transition={springs.gentle}
      className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
    >
      <span>{label}</span>
      <button onClick={onRemove}>Remove</button>
    </motion.li>
  )
}

export function AnimatedList({ items, onRemove }: {
  items: { id: string; label: string }[]
  onRemove: (id: string) => void
}) {
  return (
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
      className="space-y-2"
    >
      <AnimatePresence mode="popLayout">
        {items.map((item) => (
          <ListItem
            key={item.id}
            label={item.label}
            onRemove={() => onRemove(item.id)}
          />
        ))}
      </AnimatePresence>
    </motion.ul>
  )
}

約束 / 非目標

此技能不涵蓋:

  • 令牌和彈簧定義 → 請參閱 motion-foundations
  • 拖曳互動、滑動手勢、可重新排序列表 → 請參閱 motion-advanced
  • 文字動畫(單字/字元揭示、計數器) → 請參閱 motion-advanced
  • SVG 路徑繪製或變形 → 請參閱 motion-advanced
  • 自訂動畫鉤子 → 請參閱 motion-advanced
  • 不使用 motion/react 的純 CSS 轉場

反模式

反模式違反的規則修復方法
AnimatePresence 子元素缺少 key規則 1為直接子元素添加穩定的 key
有 initial + animate 但沒有 exit規則 2始終將三者一起定義
頁面轉場沒有 mode="wait"規則 3為 AnimatePresence 添加 mode="wait"
在包含 50 個項目的列表上使用 layout規則 4使用 mode="popLayout" 或明確的變換
在包含 10 個項目的列表上使用 staggerChildren: 0.2規則 5限制在 0.08–0.10 範圍內
模態框沒有焦點陷阱規則 6添加 focus-trap-react 或 Radix Dialog
whileInView 沒有 viewport={{ once: true }}規則 7重複進場會分散注意力,而非提供資訊
內聯 transition={{ duration: 0.3 }}規則 8使用 motionTokens.duration.normal

相關技能

  • motion-foundations — 定義此處每個模式所導入的所有令牌、彈簧、useSafeMotion 鉤子和 SSR 防護。必須先設定好。
  • motion-advanced — 以拖曳、手勢、SVG、文字、自訂鉤子和命令式序列擴展這些模式。不會重新定義此技能中的任何模式。

相關技能