Files
app-261004/components/motion/motion-item.tsx
T
2026-10-08 11:09:37 +08:00

79 lines
2.3 KiB
TypeScript

"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<HTMLDivElement>(null);
const [animationClass, setAnimationClass] = useState<string>("");
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 (
<div
ref={ref}
className={cn(
"motion-safe:transition-[opacity,transform] motion-safe:duration-500 motion-safe:ease-brand",
animationClass,
className,
)}
style={delay > 0 ? { transitionDelay: `${delay}s` } : undefined}
>
{children}
</div>
);
}