"use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; /** * 入场动画包装器。 * * ## 设计取舍(重要) * * 这里**没有**使用 motion 的 `whileInView`。原因是它在服务端会输出 * `opacity: 0`,只有 JS 执行且元素滚入视口后才显示。对博客来说这不可接受: * JS 失败、被拦截或加载缓慢时,**整份文章列表会永久不可见**,同时 * 搜索引擎抓到的也是空内容。 * * 改为「渐进增强」策略: * 1. 服务端输出完全可见的 HTML —— 无 JS 也能正常阅读,SEO 无风险; * 2. 客户端挂载后,仅当元素**尚未进入视口**时,才加上入场动画的起始类, * 并在下一帧移除,从而播放一次淡入上移。 * * 换句话说:可读性是默认状态,动画是额外叠加的效果。 */ export interface MotionItemProps { children: ReactNode; delay?: number; className?: string; } export function MotionItem({ children, delay = 0, className }: MotionItemProps) { const ref = useRef(null); const [animationClass, setAnimationClass] = useState(""); useEffect(() => { const element = ref.current; if (!element) { return; } // 尊重系统的「减弱动态效果」偏好 const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; if (prefersReducedMotion) { return; } // 已经出现在首屏内的元素不播放动画,避免可见内容的闪烁 const rect = element.getBoundingClientRect(); const isBelowFold = rect.top > window.innerHeight; if (!isBelowFold) { return; } // 仅在元素位于视口下方时才设置动画起始态 setAnimationClass("opacity-0 translate-y-3"); // 下一帧移除起始类,触发 CSS transition 过渡到最终状态 const frame = requestAnimationFrame(() => { requestAnimationFrame(() => setAnimationClass("")); }); return () => cancelAnimationFrame(frame); }, []); return (
0 ? { transitionDelay: `${delay}s` } : undefined} > {children}
); }