74 lines
1.8 KiB
TypeScript
74 lines
1.8 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
/**
|
||
* 通用入场动画包装器(渐进增强)。
|
||
*
|
||
* 与 MotionItem 采用同一策略:服务端输出的 HTML 默认可见,
|
||
* 仅在客户端确认元素位于视口下方时,才叠加一次淡入上移动画。
|
||
*
|
||
* 这样即使 JS 完全不可用,内容依然完整可读——动画纯粹是增强项。
|
||
*/
|
||
export interface RevealProps {
|
||
children: ReactNode;
|
||
className?: string;
|
||
/** 延迟秒数,用于列表的错位入场 */
|
||
delay?: number;
|
||
/** 位移距离(Tailwind 间距类,如 "translate-y-3") */
|
||
offsetClassName?: string;
|
||
}
|
||
|
||
export function Reveal({
|
||
children,
|
||
className,
|
||
delay = 0,
|
||
offsetClassName = "translate-y-4",
|
||
}: RevealProps) {
|
||
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();
|
||
if (rect.top <= window.innerHeight) {
|
||
return;
|
||
}
|
||
|
||
setAnimationClass(cn("opacity-0", offsetClassName));
|
||
|
||
const frame = requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => setAnimationClass(""));
|
||
});
|
||
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [offsetClassName]);
|
||
|
||
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>
|
||
);
|
||
}
|