react-element-in-viewport
一个开源 React 组件包,把"元素滚入视口时触发入场动画"这件事收敛到一个组件 + 一个 prop,内置 90+ 动画,零运行时依赖,发布到 npm 自用也供他人用。一、为什么造这个轮子
scroll-triggered 入场动画在营销站、长落地页里几乎是标配。但每次徒手写都要重复同一套:
// 又来一次:
const ref = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('animate-fade-in-up')
observer.unobserve(e.target) // 不要忘了
}
})
}, { threshold: 0.1 })
if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
}, [])
// ……然后还要写 CSS keyframes、还要管理 .animate-* 类名……
每个项目都重新写一遍这 30 行 + 一堆 keyframes,就是这个包想消灭的事。 目标只有一个:让"元素入场动画"变成一行 JSX。
<ElementInViewport animation="fadeInUp">
<Card />
</ElementInViewport>
包内置了 90+ 个 animate.css 风格的动画名(bounce / fade / slide / zoom / rotate / flip / lightspeed / jello / jackInTheBox / hinge …),覆盖了 99% 的"我就是想让卡片从下面飘上来"。
二、API 设计的几个判断
1. 组件式,不是 hook 式
社区里有 react-intersection-observer(hook),也有 react-animate-on-scroll(组件)。
这个包选组件式,理由:
- Hook 的责任是给你
isIntersecting这个状态,动画还是要自己写——没收敛多少东西 - 组件能把"观察 + 动画类名 + 一次性"全包,使用方零认知负担
- 但 escape hatch 也要留——
children可以是(isIntersecting) => ReactNode,复杂情况退化成 render-prop
这种"默认极简,复杂场景退化"的 API 形状是设计上的核心权衡—— 不让简单的事情变难,也不让难的事情变不可能。
2. 一次性观察,触发后自动 unobserve
绝大多数入场动画只想触发一次——卡片飘进来一次就好,反复滚动不该反复抖。
所以内部逻辑是 isIntersecting === true 后立刻 observer.unobserve(target),
释放观察器、不再触发 re-render——对长列表(几十个卡片)的性能差距是数量级的。
如果用户真的想要反复触发,给个 repeat prop 退化回常驻观察——又是"默认正确,需要时可关"的设计。
3. 配置项透传,不重复造轮子
IntersectionObserver 本身已经提供了 root / rootMargin / threshold 三个核心配置,
还有 animate.css 的 CSS 变量(--animate-duration / --animate-delay)。
包里不再造一套自己的 prop 名映射,而是直接透传:
<ElementInViewport
animation="fadeInUp"
threshold={0.3}
rootMargin="0px 0px -100px 0px"
style={{ ['--animate-duration' as any]: '1.5s' }}
>
...
</ElementInViewport>
学过 IntersectionObserver 的人零成本上手,没有"看完 README 再看官方文档对照"的负担。
三、构建管线:microbundle + style2js 的多格式打包
发布到 npm 上的包要同时满足:
- 老项目 CommonJS
require - 新项目 ESM
import - 浏览器 CDN UMD(unpkg)
- TypeScript 类型声明
- CSS 既能用户单独引入,也能内联到 JS 里"零配置"使用
整个 build 脚本被拆成 4 步流水线:
npm run clean # rimraf dist
npm run build:core # microbundle 出三种格式 + .d.ts
npm run pack-and-extract # yarn pack 后解到本地 node_modules 自检
npm run style # sass → cssnano → style2js(CSS 转 JS 字符串)
style2js 这一步是关键:把压缩后的 CSS 转成一个 injectStyle() JS 模块,
用户可以选择:
// 方案 A:传统 CSS 引入
import 'react-element-in-viewport/dist/ReactElementInViewport.css'
// 方案 B:零配置,自动注入到 <head>
import 'react-element-in-viewport/inject-style'
方案 B 对 Next.js App Router 用户特别友好——RSC 边界上不需要手动 import CSS。 代价是 +几 KB JS 体积,但换来"装完就能用"的体验,对一个小工具包很值。
microbundle 而不是 tsup / unbuild 的理由:成熟稳定,对 React 组件包这种简单场景配置最少,
exports 字段一键生成,能直接产出 umd:main 字段所需的 UMD 格式。
四、Next.js App Router 兼容
React Server Components 在 2023 年还在快速演化,第三方组件库要么不管,要么手动让用户加 'use client'。
这个包在 v2.0 的源码顶部预先加了 'use client' 指令,发布到 dist 里——
用户在 App Router 项目里 import 就能直接当客户端组件用,不需要在自己代码里包一层。
这是个 小而正确 的决策——一旦 RSC 普及,没做这件事的组件库会反复被用户提 issue。
五、测试与发布纪律
jest+@testing-library/react跑组件行为测试(IntersectionObserver 用mock注入)@testing-library/jest-dom提供更可读的断言- 每次 release 更新
CHANGELOG.md,遵循 SemVer peerDependencies: { react: ">=16" }—— 不绑死 React 版本,老项目也能装
对一个自己写自己用、顺便公开的包来说,这些纪律的真实价值不是给外部用户的承诺, 而是未来的自己升级、回退、定位 bug 时的安全网。
六、目录结构
src/
├── components/ # ElementInViewport 主组件
├── types/ # 类型定义(animation 名、props 等)
├── utils/ # className 拼接、observer 工厂
└── index.ts # 公开导出
scss/
└── main.scss # 90+ 动画的 keyframes 源文件(编译后 → dist/*.css)
example/ # Vite 起的本地 demo + 文档站源码
├── components/ # 演示卡片
├── data/ # 动画名清单
└── ... # 部署到 yunstv.github.io/react-element-in-viewport/
dist/ # 构建产物(CJS / ESM / UMD / .d.ts / .css / inject-style)
example/ 既是开发时的"边写边看"环境,也是用户的文档站——
GitHub Pages 直接托管,在线 demo 一键可达。
七、谁在用
这个包不只是"发出去自我安慰"——unibase 官网 的 package.json 里
就静静躺着一行 "react-element-in-viewport": "1.1.0",
首页那些卡片飘入、Section 渐显都是它做的。
自己写、自己先用一年,确认 API 顺手、bug 修干净后再发版本——这是开源能避免"为社区写代码"陷阱的方式。
八、时间线
- 2023-10-25 初始化
- v1.x 打磨 API、补完动画、稳定 IntersectionObserver 边界条件
- v2.0.0 升级到 React 19、加
'use client'、构建管线切到 microbundle 主线、补全 ESM exports map - 至今(2026) 仍在迭代,主要随 React / Next.js 大版本升级走
总结:一个小包的"克制"
90+ 动画听起来像功能堆叠,但回头看做对的几件事都是减法:
- API 表面只有 1 个组件 + 1 个必填 prop(其它都有默认值)
- 配置项不另造,直接透传 IntersectionObserver 原生选项
- 默认一次性观察,默认正确而不是"默认灵活"
- 多格式打包 + style2js 注入,用户零配置
一个小工具包真正的价值在**"装上不用学"**—— 不让用户读 README 也能拼出最常见的用法。