This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
"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>
);
}