motion-ui

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Motion System v4.2

Motion System v4.2

Production-ready UI motion system for React / Next.js.
Focused on performance, accessibility, and usability — not decoration.
适用于React / Next.js的可投入生产的UI动效系统。
专注于性能、可访问性与可用性——而非装饰性效果。

When to Use

适用场景

Use this motion system when motion:
  • Guides attention (e.g., onboarding, key actions)
  • Communicates state (loading, success, error, transitions)
  • Preserves spatial continuity (layout changes, navigation)
在以下动效场景中使用本系统:
  • 引导注意力(如:新手引导、关键操作提示)
  • 传达状态(加载中、成功、错误、过渡状态)
  • 保持空间连续性(布局变更、导航切换)

Appropriate Scenarios

合适的场景

  • Interactive components (buttons, modals, menus)
  • State transitions (loading → loaded, open → closed)
  • Navigation and layout continuity (shared elements, crossfade)
  • 交互式组件(按钮、模态框、菜单)
  • 状态过渡(加载中→加载完成、展开→收起)
  • 导航与布局连续性(共享元素、淡入淡出切换)

Considerations

注意事项

  • Accessibility: Always support reduced motion
  • Device adaptation: Adjust for low-end devices
  • Performance trade-offs: Prefer responsiveness over visual smoothness
  • 可访问性:始终支持减少动效的设置
  • 设备适配:针对低端设备调整动效
  • 性能权衡:优先保证响应性而非视觉流畅度

Avoid Using Motion When

避免使用动效的场景

  • It is purely decorative
  • It reduces usability or clarity
  • It impacts performance negatively

  • 仅作为装饰用途
  • 降低可用性或清晰度
  • 对性能产生负面影响

How It Works

工作原理

Core Principle

核心原则

Motion must:
  • Guide attention
  • Communicate state
  • Preserve spatial continuity
If it does none → remove it.

动效必须满足以下至少一项:
  • 引导注意力
  • 传达状态
  • 保持空间连续性
若均不满足→移除该动效。

Installation

安装

bash
npm install motion

bash
npm install motion

Version

版本说明

  • motion/react
    - default for current Motion for React projects (package:
    motion
    )
  • framer-motion
    - legacy import path for projects that still depend on Framer Motion
Do not mix. Mixing causes conflicting internal schedulers and broken
AnimatePresence
contexts — components from one package will not coordinate exit animations with components from the other.
To check which version your project uses:
bash
cat package.json | grep -E '"motion"|"framer-motion"'
Always import from one source consistently:
ts
// Correct (modern)
import { motion, AnimatePresence } from "motion/react"

// Correct (legacy)
import { motion, AnimatePresence } from "framer-motion"

// Never mix both in the same project

  • motion/react
    - 当前React项目的默认版本(包名:
    motion
  • framer-motion
    - 仍依赖Framer Motion的项目使用的旧版导入路径
请勿混合使用。混合使用会导致内部调度器冲突,以及
AnimatePresence
上下文失效——来自一个包的组件无法与另一个包的组件协调退出动画。
检查项目使用的版本:
bash
cat package.json | grep -E '"motion"|"framer-motion"'
请始终保持从同一源导入:
ts
// 正确(现代版)
import { motion, AnimatePresence } from "motion/react"

// 正确(旧版)
import { motion, AnimatePresence } from "framer-motion"

// 绝不要在同一项目中混合使用两者

Motion Tokens

动效令牌

ts
// motionTokens.ts
export const motionTokens = {
  duration: {
    fast: 0.18,
    normal: 0.35,
    slow: 0.6
  },
  // Use these as the `ease` value inside a `transition` object:
  // transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }}
  easing: {
    smooth: [0.22, 1, 0.36, 1] as [number, number, number, number],
    sharp:  [0.4,  0, 0.2, 1] as [number, number, number, number]
  },
  distance: {
    sm: 8,
    md: 16,
    lg: 24
  }
}
Usage example:
tsx
import { motionTokens } from "@/lib/motionTokens"

<motion.div
  initial={{ opacity: 0, y: motionTokens.distance.md }}
  animate={{ opacity: 1, y: 0 }}
  transition={{
    duration: motionTokens.duration.normal,
    ease: motionTokens.easing.smooth
  }}
/>

ts
// motionTokens.ts
export const motionTokens = {
  duration: {
    fast: 0.18,
    normal: 0.35,
    slow: 0.6
  },
  // 在`transition`对象中使用这些值作为`ease`参数:
  // transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }}
  easing: {
    smooth: [0.22, 1, 0.36, 1] as [number, number, number, number],
    sharp:  [0.4,  0, 0.2, 1] as [number, number, number, number]
  },
  distance: {
    sm: 8,
    md: 16,
    lg: 24
  }
}
使用示例:
tsx
import { motionTokens } from "@/lib/motionTokens"

<motion.div
  initial={{ opacity: 0, y: motionTokens.distance.md }}
  animate={{ opacity: 1, y: 0 }}
  transition={{
    duration: motionTokens.duration.normal,
    ease: motionTokens.easing.smooth
  }}
/>

Performance Rules

性能规则

Safe
  • transform
  • opacity
Avoid
  • width / height
  • top / left
Rule: responsiveness > smoothness

安全属性
  • transform
  • opacity
应避免的属性
  • width / height
  • top / left
规则:响应性 > 流畅度

Device Adaptation

设备适配

The heuristic combines CPU core count and available memory for a more reliable signal.
deviceMemory
is available on Chrome/Android; the fallback covers Safari and Firefox.
ts
const isLowEnd =
  typeof navigator !== "undefined" && (
    // Low memory (Chrome/Android only; undefined elsewhere → treat as capable)
    (navigator.deviceMemory !== undefined && navigator.deviceMemory <= 2) ||
    // Few cores AND no memory API (covers Safari/Firefox on weak hardware)
    (navigator.deviceMemory === undefined && navigator.hardwareConcurrency <= 4)
  )

const duration = isLowEnd ? 0.2 : 0.4

该判断逻辑结合了CPU核心数可用内存,以获得更可靠的信号。
deviceMemory
在Chrome/Android中可用;回退逻辑覆盖Safari和Firefox。
ts
const isLowEnd =
  typeof navigator !== "undefined" && (
    // 低内存(仅Chrome/Android支持;其他环境下为undefined→视为高性能设备)
    (navigator.deviceMemory !== undefined && navigator.deviceMemory <= 2) ||
    // 核心数少且无内存API(覆盖弱硬件上的Safari/Firefox)
    (navigator.deviceMemory === undefined && navigator.hardwareConcurrency <= 4)
  )

const duration = isLowEnd ? 0.2 : 0.4

Accessibility

可访问性

JS (useReducedMotion)

JS(useReducedMotion)

tsx
import { motion, useReducedMotion } from "motion/react"

export function FadeIn() {
  const reduce = useReducedMotion()

  return (
    <motion.div
      initial={{ opacity: 0, y: reduce ? 0 : 24 }}
      animate={{ opacity: 1, y: 0 }}
    />
  )
}
tsx
import { motion, useReducedMotion } from "motion/react"

export function FadeIn() {
  const reduce = useReducedMotion()

  return (
    <motion.div
      initial={{ opacity: 0, y: reduce ? 0 : 24 }}
      animate={{ opacity: 1, y: 0 }}
    />
  )
}

CSS

CSS

css
@media (prefers-reduced-motion: reduce) {
  .motion-safe-transition {
    transition: opacity 0.2s;
  }

  .motion-reduce-transform {
    transform: none !important;
  }
}
css
@media (prefers-reduced-motion: reduce) {
  .motion-safe-transition {
    transition: opacity 0.2s;
  }

  .motion-reduce-transform {
    transform: none !important;
  }
}

Tailwind

Tailwind

html
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>

html
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>

Architecture & Patterns

架构与模式

Core Patterns

核心模式

ScenarioPattern
Hover feedback
whileHover
Tap / press feedback
whileTap
Reveal on scroll
whileInView
Scroll-linked value
useScroll
+
useTransform
Conditional mount/unmount
AnimatePresence
Small layout shifts (single element, < ~300px change)
layout
prop
Large layout shifts or full-page reflowsAvoid
layout
; use CSS transitions or page-level routing instead
Complex, imperative sequences
useAnimate
Why avoid
layout
on large containers?
Framer's layout animation uses
transform
to reconcile positions, but on elements that span the full viewport or trigger deep reflow, the measurement cost causes visible jank and CLS. Prefer CSS Grid/Flexbox transitions or coordinate with
layoutId
on specific child elements only.
场景模式
悬停反馈
whileHover
点击/按压反馈
whileTap
滚动时显示
whileInView
滚动关联值
useScroll
+
useTransform
条件挂载/卸载
AnimatePresence
小范围布局偏移(单个元素,变化<~300px)
layout
属性
大范围布局偏移或整页重排避免使用
layout
;改用CSS过渡或页面级路由
复杂的命令式序列
useAnimate
为何避免在大型容器上使用
layout
Framer的布局动画使用
transform
来协调位置,但在跨整个视口或触发深层重排的元素上,测量成本会导致可见的卡顿和CLS(累积布局偏移)。优先使用CSS Grid/Flexbox过渡,或仅在特定子元素上配合
layoutId
使用。

Layout & Transitions

布局与过渡

  • Shared element transitions →
    layoutId
    (must be unique per mounted instance)
  • Enter / exit transitions →
    AnimatePresence
    (see
    mode
    guidance below)
  • 共享元素过渡 →
    layoutId
    (每个挂载实例必须唯一)
  • 进入/退出过渡 →
    AnimatePresence
    (参见下方
    mode
    指南)

AnimatePresence
mode

AnimatePresence
mode

Always specify
mode
explicitly — the default (
"sync"
) runs enter and exit simultaneously, which causes visual overlap in most UI patterns.
mode
When to use
"wait"
Exit completes before enter starts. Use for modals, toasts, page transitions.
"sync"
(default)
Enter and exit overlap. Use only when overlap is intentional (e.g., crossfade carousels).
"popLayout"
Exiting element is popped out of flow immediately; remaining items animate to fill. Use for lists, tabs, dismissible cards.
tsx
// Modal — always use "wait"
<AnimatePresence mode="wait">
  {open && <Modal key="modal" />}
</AnimatePresence>

// Dismissible list item — use "popLayout"
<AnimatePresence mode="popLayout">
  {items.map(item => <Card key={item.id} />)}
</AnimatePresence>

请始终显式指定
mode
——默认值(
"sync"
)会同时运行进入和退出动画,这在大多数UI模式中会导致视觉重叠。
mode
使用场景
"wait"
退出动画完成后再开始进入动画。用于模态框、提示框、页面过渡
"sync"
(默认)
进入和退出动画重叠。仅在需要故意重叠时使用(如:淡入淡出轮播)。
"popLayout"
退出元素立即从流中移除;剩余元素动画填充空位。用于列表、标签页、可关闭卡片
tsx
// 模态框——始终使用"wait"
<AnimatePresence mode="wait">
  {open && <Modal key="modal" />}
</AnimatePresence>

// 可关闭列表项——使用"popLayout"
<AnimatePresence mode="popLayout">
  {items.map(item => <Card key={item.id} />)}
</AnimatePresence>

Advanced Patterns (Concepts)

进阶模式(概念)

  • Parallax (scroll-linked transforms)
  • Scroll storytelling (sticky sections)
  • 3D tilt (pointer-based transforms)
  • Crossfade (shared
    layoutId
    )
  • Progressive reveal (clip-path)
  • Skeleton loading (looped opacity)
  • Micro-interactions (hover/tap feedback)
  • Spring system (physics-based motion)

  • 视差效果(滚动关联变换)
  • 滚动叙事(粘性区块)
  • 3D倾斜(基于指针的变换)
  • 淡入淡出切换(共享
    layoutId
  • 渐进式显示(clip-path)
  • 骨架屏加载(循环透明度动画)
  • 微交互(悬停/点击反馈)
  • 弹簧系统(基于物理的动效)

Modal Essentials

模态框要点

  • Focus trap
  • Escape close
  • Scroll lock
  • ARIA roles
  • Use
    AnimatePresence mode="wait"
    so exit animation completes before the next modal enters
  • 焦点陷阱
  • 按ESC关闭
  • 滚动锁定
  • ARIA角色
  • 使用
    AnimatePresence mode="wait"
    ,确保退出动画完成后再进入下一个模态框

Full Example

完整示例

tsx
import React, { useEffect, useRef, useState } from "react"
import { motion, AnimatePresence } from "motion/react"

function useFocusTrap(ref: React.RefObject<HTMLDivElement | null>, active: boolean) {
  useEffect(() => {
    if (!active || !ref.current) return
    const el = ref.current
    const focusable = el.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const first = focusable[0]
    const last  = focusable[focusable.length - 1]

    function handleKey(e: KeyboardEvent) {
      if (e.key !== "Tab") return
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault()
        last?.focus()
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault()
        first?.focus()
      }
    }

    el.addEventListener("keydown", handleKey)
    first?.focus()
    return () => el.removeEventListener("keydown", handleKey)
  }, [active, ref])
}

function useScrollLock(active: boolean) {
  useEffect(() => {
    if (!active) return
    const prev = document.body.style.overflow
    document.body.style.overflow = "hidden"
    return () => { document.body.style.overflow = prev }
  }, [active])
}

function Modal({ open, closeModal }: { open: boolean; closeModal: () => void }) {
  const ref = useRef<HTMLDivElement>(null)

  useFocusTrap(ref, open)
  useScrollLock(open)

  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") closeModal()
    }
    if (open) window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  }, [open, closeModal])

  return (
    // mode="wait" ensures exit animation finishes before any new modal enters
    <AnimatePresence mode="wait">
      {open && (
        <motion.div
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.2 }}
          className="fixed inset-0 flex items-center justify-center bg-black/40"
        >
          <motion.div
            ref={ref}
            initial={{ scale: 0.95, opacity: 0 }}
            animate={{ scale: 1,    opacity: 1 }}
            exit={{    scale: 0.95, opacity: 0 }}
            transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
            className="bg-white p-6 rounded"
          >
            <h2 id="modal-title">Dialog Title</h2>
            <button onClick={closeModal}>Close</button>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  )
}

export function Example() {
  const [open, setOpen] = useState(false)

  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>
      <Modal open={open} closeModal={() => setOpen(false)} />
    </>
  )
}

tsx
import React, { useEffect, useRef, useState } from "react"
import { motion, AnimatePresence } from "motion/react"

function useFocusTrap(ref: React.RefObject<HTMLDivElement | null>, active: boolean) {
  useEffect(() => {
    if (!active || !ref.current) return
    const el = ref.current
    const focusable = el.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const first = focusable[0]
    const last  = focusable[focusable.length - 1]

    function handleKey(e: KeyboardEvent) {
      if (e.key !== "Tab") return
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault()
        last?.focus()
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault()
        first?.focus()
      }
    }

    el.addEventListener("keydown", handleKey)
    first?.focus()
    return () => el.removeEventListener("keydown", handleKey)
  }, [active, ref])
}

function useScrollLock(active: boolean) {
  useEffect(() => {
    if (!active) return
    const prev = document.body.style.overflow
    document.body.style.overflow = "hidden"
    return () => { document.body.style.overflow = prev }
  }, [active])
}

function Modal({ open, closeModal }: { open: boolean; closeModal: () => void }) {
  const ref = useRef<HTMLDivElement>(null)

  useFocusTrap(ref, open)
  useScrollLock(open)

  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") closeModal()
    }
    if (open) window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  }, [open, closeModal])

  return (
    // mode="wait"确保退出动画完成后再进入新的模态框
    <AnimatePresence mode="wait">
      {open && (
        <motion.div
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.2 }}
          className="fixed inset-0 flex items-center justify-center bg-black/40"
        >
          <motion.div
            ref={ref}
            initial={{ scale: 0.95, opacity: 0 }}
            animate={{ scale: 1,    opacity: 1 }}
            exit={{    scale: 0.95, opacity: 0 }}
            transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
            className="bg-white p-6 rounded"
          >
            <h2 id="modal-title">对话框标题</h2>
            <button onClick={closeModal}>关闭</button>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  )
}

export function Example() {
  const [open, setOpen] = useState(false)

  return (
    <>
      <button onClick={() => setOpen(true)}>打开</button>
      <Modal open={open} closeModal={() => setOpen(false)} />
    </>
  )
}

SSR Safety

SSR安全

  • Match initial states between server and client renders
  • Avoid implicit animation origins (always set
    initial
    explicitly)
  • Wrap motion components in
    "use client"
    in Next.js App Router

  • 保持服务端与客户端渲染的初始状态一致
  • 避免隐式动画起点(始终显式设置
    initial
  • 在Next.js App Router中,将动效组件包裹在
    "use client"

Debugging

调试

Check:
  • Wrong import (mixing
    motion/react
    and
    framer-motion
    )
  • Missing
    "use client"
    directive in Next.js App Router
  • Missing
    key
    prop on
    AnimatePresence
    children
  • Hydration mismatch (initial state differs between SSR and client)
  • layout
    prop misuse on large containers causing reflow jank
  • State-driven animation not triggering (check dependency arrays)

检查以下内容:
  • 错误的导入(混合使用
    motion/react
    framer-motion
  • Next.js App Router中缺少
    "use client"
    指令
  • AnimatePresence
    子元素缺少
    key
    属性
  • 水化不匹配(SSR与客户端初始状态不同)
  • 在大型容器上错误使用
    layout
    属性导致重排卡顿
  • 状态驱动的动画未触发(检查依赖数组)

QA

QA检查项

  • No CLS
  • Keyboard works
  • Focus trapped in modals
  • ARIA roles correct (
    role="dialog"
    ,
    aria-modal="true"
    )
  • Reduced motion respected (
    useReducedMotion
    + CSS media query)
  • No hydration warnings in Next.js
  • Animations stop cleanly on unmount (no memory leaks)
  • AnimatePresence mode
    set explicitly on all usage sites

  • 无CLS(累积布局偏移)
  • 键盘操作正常
  • 模态框中焦点被正确捕获
  • ARIA角色正确(
    role="dialog"
    ,
    aria-modal="true"
  • 尊重减少动效的偏好(
    useReducedMotion
    + CSS媒体查询)
  • Next.js中无水化警告
  • 卸载时动画干净停止(无内存泄漏)
  • 所有
    AnimatePresence
    使用场景均显式设置
    mode

Anti-Patterns

反模式

  • Animating layout properties (
    width
    ,
    height
    ,
    top
    ,
    left
    )
  • Infinite animations without purpose (always ask: what state does this communicate?)
  • Over-staggering lists (keep
    staggerChildren
    ≤ 0.1s; beyond that it feels slow)
  • Ignoring reduced motion preferences
  • Using
    layout
    on large or full-viewport containers
  • Omitting
    mode
    on
    AnimatePresence
    (default
    "sync"
    causes visual overlap)
  • Using motion purely for decoration

  • 对布局属性设置动画(
    width
    ,
    height
    ,
    top
    ,
    left
  • 无意义的无限动画(始终问自己:这传达了什么状态?)
  • 列表动画过度错开(
    staggerChildren
    ≤ 0.1s;超过这个值会感觉缓慢)
  • 忽略减少动效的偏好
  • 在大型或全屏容器上使用
    layout
  • AnimatePresence
    省略
    mode
    (默认
    "sync"
    会导致视觉重叠)
  • 动效仅用于装饰

Philosophy

设计理念

Motion is interaction design.

动效是交互设计的一部分。

Final Rule

最终规则

If motion does not improve UX → remove it.

如果动效没有提升用户体验→移除它。

Examples

示例

Button Interaction

按钮交互

tsx
import { motion } from "motion/react"

export function Button() {
  return (
    <motion.button
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.97 }}
      transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
    >
      Click me
    </motion.button>
  )
}

tsx
import { motion } from "motion/react"

export function Button() {
  return (
    <motion.button
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.97 }}
      transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
    >
      点击我
    </motion.button>
  )
}

Reduced Motion Example

减少动效示例

tsx
import { motion, useReducedMotion } from "motion/react"

export function FadeIn() {
  const reduce = useReducedMotion()

  return (
    <motion.div
      initial={{ opacity: 0, y: reduce ? 0 : 24 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: reduce ? 0.1 : 0.35, ease: [0.22, 1, 0.36, 1] }}
    />
  )
}

tsx
import { motion, useReducedMotion } from "motion/react"

export function FadeIn() {
  const reduce = useReducedMotion()

  return (
    <motion.div
      initial={{ opacity: 0, y: reduce ? 0 : 24 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: reduce ? 0.1 : 0.35, ease: [0.22, 1, 0.36, 1] }}
    />
  )
}

Stagger List

错开列表动画

tsx
import { motion } from "motion/react"

const container = {
  hidden: {},
  visible: {
    transition: { staggerChildren: 0.08 } // keep ≤ 0.1s to avoid sluggishness
  }
}

const item = {
  hidden:  { opacity: 0, y: 10 },
  visible: { opacity: 1, y: 0,  transition: { duration: 0.3, ease: [0.22, 1, 0.36, 1] } }
}

export function List() {
  return (
    <motion.ul variants={container} initial="hidden" animate="visible">
      {[1, 2, 3].map(i => (
        <motion.li key={i} variants={item}>Item {i}</motion.li>
      ))}
    </motion.ul>
  )
}

tsx
import { motion } from "motion/react"

const container = {
  hidden: {},
  visible: {
    transition: { staggerChildren: 0.08 } // 保持≤0.1s以避免卡顿感
  }
}

const item = {
  hidden:  { opacity: 0, y: 10 },
  visible: { opacity: 1, y: 0,  transition: { duration: 0.3, ease: [0.22, 1, 0.36, 1] } }
}

export function List() {
  return (
    <motion.ul variants={container} initial="hidden" animate="visible">
      {[1, 2, 3].map(i => (
        <motion.li key={i} variants={item}>项目 {i}</motion.li>
      ))}
    </motion.ul>
  )
}

Modal with AnimatePresence

带AnimatePresence的模态框

tsx
import { motion, AnimatePresence } from "motion/react"

export function Modal({ open }: { open: boolean }) {
  return (
    <AnimatePresence mode="wait">
      {open && (
        <motion.div
          initial={{ opacity: 0, scale: 0.95 }}
          animate={{ opacity: 1, scale: 1    }}
          exit={{    opacity: 0, scale: 0.95 }}
          transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
        />
      )}
    </AnimatePresence>
  )
}

tsx
import { motion, AnimatePresence } from "motion/react"

export function Modal({ open }: { open: boolean }) {
  return (
    <AnimatePresence mode="wait">
      {open && (
        <motion.div
          initial={{ opacity: 0, scale: 0.95 }}
          animate={{ opacity: 1, scale: 1    }}
          exit={{    opacity: 0, scale: 0.95 }}
          transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
        />
      )}
    </AnimatePresence>
  )
}

Scroll Parallax

滚动视差

tsx
import { useScroll, useTransform, motion } from "motion/react"

export function Parallax() {
  const { scrollYProgress } = useScroll()
  const y = useTransform(scrollYProgress, [0, 1], [0, -80])

  return <motion.div style={{ y }} />
}

tsx
import { useScroll, useTransform, motion } from "motion/react"

export function Parallax() {
  const { scrollYProgress } = useScroll()
  const y = useTransform(scrollYProgress, [0, 1], [0, -80])

  return <motion.div style={{ y }} />
}

Skeleton Loading

骨架屏加载

tsx
import { motion } from "motion/react"

export function Skeleton() {
  return (
    <motion.div
      className="bg-gray-200 h-6 w-full rounded"
      animate={{ opacity: [0.5, 1, 0.5] }}
      transition={{
        duration: 1.5,       // comfortable pulse — was missing, caused fast flash
        repeat: Infinity,
        ease: "easeInOut"
      }}
    />
  )
}

tsx
import { motion } from "motion/react"

export function Skeleton() {
  return (
    <motion.div
      className="bg-gray-200 h-6 w-full rounded"
      animate={{ opacity: [0.5, 1, 0.5] }}
      transition={{
        duration: 1.5,       // 舒适的脉冲效果——之前缺失导致快速闪烁
        repeat: Infinity,
        ease: "easeInOut"
      }}
    />
  )
}

Shared Layout (Crossfade)

共享布局(淡入淡出切换)

tsx
import { motion } from "motion/react"

// layoutId must be unique per mounted instance.
// If multiple instances can exist simultaneously, append a unique id:
// layoutId={`shared-${item.id}`}
export function Shared() {
  return <motion.div layoutId="shared" />
}
tsx
import { motion } from "motion/react"

// layoutId必须每个挂载实例唯一。
// 如果可能同时存在多个实例,请追加唯一id:
// layoutId={`shared-${item.id}`}
export function Shared() {
  return <motion.div layoutId="shared" />
}