This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,78 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion, useScroll, useSpring } from "motion/react";
|
||||
|
||||
/**
|
||||
* 页面顶部阅读进度条。
|
||||
*
|
||||
* 使用 motion 的 `useScroll` + `useSpring`:滚动位置映射为宽度百分比,
|
||||
* 经弹簧平滑后呈现,避免直接跟随时产生的抖动。
|
||||
*/
|
||||
export function ReadingProgress() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 220,
|
||||
damping: 40,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
style={{ scaleX }}
|
||||
className="fixed inset-x-0 top-0 z-50 h-[2px] origin-left bg-brand-green"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user