sai-assets可复用网页资产库

Motion Kit · 动效层(十模块)

motion-kit v1.0.0 MIT 网页动效 8 文件 · 79 KB
新窗口 ↗
载入预览…

长页 / 落地页 / 作品集要在零依赖前提下加一层动效时:滚动入场(位移+模糊)、标题按词逐字上浮、子项错峰、数字滚动、卡面倾斜+指针光斑、磁吸控件、跟随光标+上下文标签、首屏 canvas 粒子场+指针光、过程轨道进度、图片滚动漂移、顶部进度线、导航当前段高亮、FAQ 高度折叠、明暗切换的圆形揭示过场。每个模块只在页面上存在对应标记时才启动,可以只留你要的那几个。

不适用场景

需要 3D/WebGL 或满屏重背景(走 bg-fx / three-fx);需要整页翻页过场或文字解散回聚(走 page-transition);需要压在四角的竖排跑马灯导航(走 nav-rail-marquee);要的是「深浅两态按钮 + 无闪切换」本身(走 dual-theme-toggle,本包只做切换那一刻的圆形揭示);React/Vue 组件封装(这是无依赖经典脚本层)。

元数据

类目
网页动效
版本
v1.0.0
许可
MIT
规模
8 文件 · 79 KB
来源
make-site · assets/css/motion.css + assets/js/motion.js

标签

  • motion
  • scroll
  • reveal
  • split-text
  • counter
  • tilt
  • magnetic
  • cursor
  • hero-canvas
  • parallax
  • faq
  • view-transition
  • progressive-enhancement
  • reduced-motion

验证证据

2026-11-04 · python packs/motion-kit/scripts/assert_tokens.py(元数据/声明路径/预览引用/data-URI 除外/实现标记/站点专有标识)+ python scripts/gate.py(仓级三层闸)

PASS assert_tokens motion-kit · declared=5 entry=SKILL.md preview=yes refs=2 · src=2 files / 38895B · cues: css 11 · js 20 · docs 8 · 无站点专有标识;仓级 gate 本包 0 命中(30→31 包,29 assertions)

归属与许可

  • make.ouuut.com(自家站点的动效层) · 来源 · MIT(本家自产;站点文案与图片不在本包内)
    两文件动效层的来源:该站 landing 页上线的原版 motion.css / motion.js

取用

固定版本(CDN)
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nakamotosai/sai-assets@pack/motion-kit@1.0.0/packs/motion-kit/src/motion.css">
raw 直取(跟随 main)
curl -O https://raw.githubusercontent.com/nakamotosai/sai-assets/main/packs/motion-kit/SKILL.md
仓库内落地
python scripts/apply.py motion-kit --to <项目目录>
整包下载
https://assets.ouuut.com/downloads/motion-kit.zip

源码与规则

pack.json
{
  "schema": 1,
  "id": "motion-kit",
  "title": "Motion Kit · 动效层(十模块)",
  "kind": "fx",
  "category": "web-fx",
  "version": "1.0.0",
  "license": "MIT",
  "attribution": [
    {
      "name": "make.ouuut.com(自家站点的动效层)",
      "url": "https://make.ouuut.com/",
      "license": "MIT(本家自产;站点文案与图片不在本包内)",
      "what": "两文件动效层的来源:该站 landing 页上线的原版 motion.css / motion.js"
    }
  ],
  "origin": {
    "project": "make-site",
    "path": "assets/css/motion.css + assets/js/motion.js",
    "derived_from": null
  },
  "entry": "SKILL.md",
  "files": {
    "css": [
      "src/motion.css"
    ],
    "src": [
      "src/motion.js"
    ],
    "usage": "docs/usage.md",
    "assert": "scripts/assert_tokens.py",
    "preview": "preview/index.html"
  },
  "tags": [
    "motion",
    "scroll",
    "reveal",
    "split-text",
    "counter",
    "tilt",
    "magnetic",
    "cursor",
    "hero-canvas",
    "parallax",
    "faq",
    "view-transition",
    "progressive-enhancement",
    "reduced-motion"
  ],
  "when": "长页 / 落地页 / 作品集要在零依赖前提下加一层动效时:滚动入场(位移+模糊)、标题按词逐字上浮、子项错峰、数字滚动、卡面倾斜+指针光斑、磁吸控件、跟随光标+上下文标签、首屏 canvas 粒子场+指针光、过程轨道进度、图片滚动漂移、顶部进度线、导航当前段高亮、FAQ 高度折叠、明暗切换的圆形揭示过场。每个模块只在页面上存在对应标记时才启动,可以只留你要的那几个。",
  "not_for": "需要 3D/WebGL 或满屏重背景(走 bg-fx / three-fx);需要整页翻页过场或文字解散回聚(走 page-transition);需要压在四角的竖排跑马灯导航(走 nav-rail-marquee);要的是「深浅两态按钮 + 无闪切换」本身(走 dual-theme-toggle,本包只做切换那一刻的圆形揭示);React/Vue 组件封装(这是无依赖经典脚本层)。",
  "default_variant": null,
  "verified": {
    "date": "2026-11-04",
    "how": "python packs/motion-kit/scripts/assert_tokens.py(元数据/声明路径/预览引用/data-URI 除外/实现标记/站点专有标识)+ python scripts/gate.py(仓级三层闸)",
    "evidence": "PASS assert_tokens motion-kit · declared=5 entry=SKILL.md preview=yes refs=2 · src=2 files / 38895B · cues: css 11 · js 20 · docs 8 · 无站点专有标识;仓级 gate 本包 0 命中(30→31 包,29 assertions)"
  },
  "canonical_home": null
}
src/motion.css
/* motion-kit — 动效层
   Progressive enhancement only. Every rule keys off html.js-motion / html.motion-ready
   so no-JS and prefers-reduced-motion users keep the static design untouched.
   Monochrome only: no colored gradients, no new hues. */

:root {
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Motion-only chrome stays hidden until JS is ready. */
.hero-canvas,
.hero-glow,
.cursor,
.cursor-label,
.scroll-progress { display: none; }
html.motion-ready .hero-canvas,
html.motion-ready .hero-glow,
html.motion-ready .cursor,
html.motion-ready .cursor-label,
html.motion-ready .scroll-progress { display: block; }

/* ── Scroll progress ─────────────────────────────────────────────────── */
.scroll-progress {
  position: fixed;
  left: 0; right: 0; top: 0;
  height: 2px;
  z-index: 70;
  pointer-events: none;
}
.scroll-progress span {
  display: block;
  height: 100%;
  background: var(--foreground);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

/* ── Reveal ──────────────────────────────────────────────────────────── */
html.js-motion [data-reveal] {
  opacity: 0;
  transform: translate3d(0, 26px, 0);
  filter: blur(6px);
  transition:
    opacity 0.8s var(--ease-out),
    transform 0.9s var(--ease-out),
    filter 0.8s var(--ease-out);
  transition-delay: calc(var(--reveal-delay, 0) * 70ms);
  will-change: opacity, transform;
}
html.js-motion [data-reveal].is-inview {
  opacity: 1;
  transform: none;
  filter: blur(0);
  will-change: auto;
}

/* ── Split text ──────────────────────────────────────────────────────── */
html.js-motion .split-word {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  padding-bottom: 0.12em;
  margin-bottom: -0.12em;
}
html.js-motion .split-word > span {
  display: inline-block;
  transform: translate3d(0, 112%, 0) rotate(2deg);
  opacity: 0;
  transition: transform 0.95s var(--ease-out), opacity 0.55s linear;
  transition-delay: calc(var(--i, 0) * 55ms);
}
html.js-motion [data-split].is-inview .split-word > span {
  transform: translate3d(0, 0, 0) rotate(0deg);
  opacity: 1;
}

/* ── Hero field ──────────────────────────────────────────────────────── */
.hero { position: relative; isolation: isolate; }
.hero-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0.55;
  z-index: 0;
}
.hero-glow {
  position: absolute;
  left: 0; top: 0;
  width: 460px; height: 460px;
  margin: -230px 0 0 -230px;
  border-radius: 50%;
  background: var(--foreground);
  opacity: 0.05;
  filter: blur(70px);
  pointer-events: none;
  z-index: 0;
  transform: translate3d(-200px, -200px, 0);
}
html.motion-ready .hero-glow { opacity: 0.06; }
.hero > *:not(.hero-canvas):not(.hero-glow) { position: relative; z-index: 1; }

html.motion-ready .hero-pill .dot { animation: dot-pulse 2.6s var(--ease-in-out) infinite; }
@keyframes dot-pulse {
  0%, 100% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.7); opacity: 0.45; }
}

/* ── Ticker ──────────────────────────────────────────────────────────── */
.ticker {
  position: relative;
  overflow: hidden;
  padding: 13px 0;
  background: var(--background);
  border-top: 1px solid var(--border-hairline);
  border-bottom: 1px solid var(--border-hairline);
}
.ticker-track { display: flex; align-items: center; width: max-content; }
.ticker-item {
  margin-right: 22px;
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}
.ticker-sep { margin-right: 22px; font-size: 10px; color: var(--border-control); }
html.motion-ready .ticker-track { animation: ticker-move 36s linear infinite; }
html.motion-ready .ticker:hover .ticker-track { animation-play-state: paused; }
@keyframes ticker-move {
  from { transform: translate3d(0, 0, 0); }
  to { transform: translate3d(-50%, 0, 0); }
}

/* ── Custom cursor ───────────────────────────────────────────────────── */
.cursor {
  position: fixed;
  left: 0; top: 0;
  z-index: 90;
  pointer-events: none;
  opacity: 0;
  transform: translate3d(var(--cx, 0px), var(--cy, 0px), 0);
  transition: opacity 0.2s linear;
}
html.has-cursor .cursor.is-visible { opacity: 1; }
.cursor-ring {
  position: absolute;
  left: 0; top: 0;
  width: 30px; height: 30px;
  margin: -15px 0 0 -15px;
  border: 1px solid var(--foreground);
  border-radius: 50%;
  opacity: 0.42;
  transition: transform 0.22s var(--ease-out), opacity 0.2s linear;
}
.cursor-dot {
  position: absolute;
  left: 0; top: 0;
  width: 4px; height: 4px;
  margin: -2px 0 0 -2px;
  border-radius: 50%;
  background: var(--foreground);
}
.cursor.is-hover .cursor-ring { transform: scale(1.75); opacity: 0.85; }
.cursor.is-down .cursor-ring { transform: scale(0.75); opacity: 1; }
.cursor-label {
  position: fixed;
  left: 0; top: 0;
  z-index: 91;
  pointer-events: none;
  padding: 6px 11px;
  border-radius: 999px;
  background: var(--foreground);
  color: var(--card);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: -0.005em;
  white-space: nowrap;
  opacity: 0;
  transform: translate3d(var(--cx, 0px), var(--cy, 0px), 0) scale(0.9);
  transition: opacity 0.18s linear, transform 0.18s var(--ease-spring);
}
.cursor-label.is-visible { opacity: 1; transform: translate3d(var(--cx, 0px), var(--cy, 0px), 0) scale(1); }

/* ── Tilt + pointer glow ─────────────────────────────────────────────── */
html.motion-ready [data-tilt] {
  position: relative;
  overflow: hidden;
  transform-style: preserve-3d;
  transition:
    transform 0.5s var(--ease-out),
    box-shadow 0.3s ease,
    border-color 0.3s ease;
}
html.motion-ready [data-tilt].is-tilting {
  transition: box-shadow 0.3s ease, border-color 0.3s ease;
  transform: perspective(950px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg))
    translate3d(0, -3px, 0);
}
html.motion-ready [data-tilt]::after {
  content: "";
  position: absolute;
  left: var(--mx, 50%);
  top: var(--my, 50%);
  width: 220px; height: 220px;
  margin: -110px 0 0 -110px;
  border-radius: 50%;
  background: var(--foreground);
  opacity: 0;
  filter: blur(44px);
  pointer-events: none;
  transition: opacity 0.3s ease;
  z-index: 0;
}
html.motion-ready [data-tilt].is-tilting::after { opacity: 0.055; }
html.motion-ready [data-tilt] > * { position: relative; z-index: 1; }

/* Work card sheen (uses ::before so ::after stays the pointer glow). */
html.motion-ready .work-card { position: relative; }
html.motion-ready .work-card::before {
  content: "";
  position: absolute;
  top: -35%; bottom: -35%; left: -75%;
  width: 45%;
  background: var(--foreground);
  opacity: 0;
  transform: skewX(-16deg);
  pointer-events: none;
  z-index: 2;
}
html.motion-ready .work-card:hover::before { animation: work-sheen 0.95s var(--ease-out); }
@keyframes work-sheen {
  0% { opacity: 0; transform: translate3d(0, 0, 0) skewX(-16deg); }
  18% { opacity: 0.05; }
  100% { opacity: 0; transform: translate3d(430%, 0, 0) skewX(-16deg); }
}

/* Scroll-linked image drift inside work cards. */
html.motion-ready .work-card .work-media img {
  transform: translate3d(0, var(--py, 0px), 0) scale(1.045);
  transition: transform 0.6s var(--ease-out), clip-path 1s var(--ease-out);
}
html.motion-ready .work-card:hover .work-media img {
  transform: translate3d(0, var(--py, 0px), 0) scale(1.075);
}
html.js-motion .work-card .work-media img { clip-path: inset(0 0 100% 0); }
html.js-motion .work-card.is-inview .work-media img { clip-path: inset(0 0 0 0); }

/* ── Magnetic controls ───────────────────────────────────────────────── */
html.motion-ready [data-magnetic] {
  transform: translate3d(var(--mx, 0px), var(--my, 0px), 0);
  transition:
    transform 0.28s var(--ease-out),
    box-shadow 0.2s ease,
    opacity 0.2s ease,
    background 0.2s ease,
    border-color 0.2s ease;
}
html.motion-ready [data-magnetic]:hover {
  transform: translate3d(var(--mx, 0px), var(--my, 0px), 0) translateY(-1px);
}
html.motion-ready [data-magnetic]:active {
  transform: translate3d(var(--mx, 0px), var(--my, 0px), 0) translateY(0) scale(0.985);
}
html.motion-ready .work-all a[data-magnetic],
html.motion-ready .case-foot > a[data-magnetic] { display: inline-block; }

/* Button sheen. */
html.motion-ready .btn { position: relative; overflow: hidden; }
html.motion-ready .btn::after {
  content: "";
  position: absolute;
  top: -45%; bottom: -45%; left: -65%;
  width: 40%;
  background: currentColor;
  opacity: 0;
  transform: skewX(-18deg);
  pointer-events: none;
}
html.motion-ready .btn:hover::after { animation: btn-sheen 0.8s var(--ease-out); }
@keyframes btn-sheen {
  0% { opacity: 0; transform: translate3d(0, 0, 0) skewX(-18deg); }
  30% { opacity: 0.08; }
  100% { opacity: 0; transform: translate3d(380%, 0, 0) skewX(-18deg); }
}

/* ── Header state + nav ──────────────────────────────────────────────── */
html.motion-ready .site-header { transition: box-shadow 0.3s ease, background 0.3s ease; }
html.motion-ready .site-header.is-scrolled {
  box-shadow: 0 1px 0 var(--border-hairline), 0 16px 36px -30px rgba(0, 0, 0, 0.8);
}
html.motion-ready .header-inner { transition: padding 0.3s var(--ease-out); }
html.motion-ready .site-header.is-scrolled .header-inner { padding-top: 9px; padding-bottom: 9px; }

html.motion-ready .nav > a:not(.nav-cta) { position: relative; }
html.motion-ready .nav > a:not(.nav-cta)::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -7px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.35s var(--ease-out);
}
html.motion-ready .nav > a:not(.nav-cta):hover::after,
html.motion-ready .nav > a:not(.nav-cta).is-active::after { transform: scaleX(1); }

/* ── Section eyebrow rule ────────────────────────────────────────────── */
html.motion-ready .section-head .eyebrow { display: inline-flex; align-items: center; gap: 9px; }
html.motion-ready .section-head .eyebrow::before {
  content: "";
  width: 0;
  height: 1px;
  background: currentColor;
  opacity: 0.7;
  transition: width 0.65s var(--ease-out) 0.08s;
}
html.motion-ready .section-head.is-inview .eyebrow::before { width: 24px; }

/* ── Process rail ────────────────────────────────────────────────────── */
.process-wrap { position: relative; }
html.motion-ready .process-wrap .process-grid { border-top: 0; }
html.motion-ready .process-track {
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 1px;
  background: var(--border-hairline);
  overflow: hidden;
}
html.motion-ready .process-track span {
  display: block;
  width: 100%; height: 100%;
  background: var(--foreground);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.18s linear;
}
html.motion-ready .process-step {
  opacity: 1;
  transition: transform 0.5s var(--ease-out);
}
html.motion-ready .process-step .process-num,
html.motion-ready .process-step .process-title { transition: color 0.4s ease; }
html.motion-ready .process-step .process-title { color: var(--muted); }
html.motion-ready .process-step.is-active { transform: translateY(-2px); }
html.motion-ready .process-step.is-active .process-num,
html.motion-ready .process-step.is-active .process-title { color: var(--foreground); }

/* ── FAQ ─────────────────────────────────────────────────────────────── */
html.motion-ready .faq-answer { overflow: hidden; }
html.motion-ready .faq-item summary { transition: color 0.2s ease; }

/* ── Footer marquee ──────────────────────────────────────────────────── */
.footer-marquee {
  position: relative;
  overflow: hidden;
  padding: 20px 0 4px;
  border-top: 1px solid rgba(250, 250, 249, 0.12);
}
.footer-marquee-track { display: flex; align-items: center; width: max-content; }
.footer-marquee-track span {
  margin-right: 28px;
  font-size: clamp(34px, 7vw, 76px);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1;
  color: rgba(250, 250, 249, 0.42);
  white-space: nowrap;
}
.footer-marquee-track .footer-marquee-dot {
  font-size: 16px;
  color: rgba(250, 250, 249, 0.45);
}
html.motion-ready .footer-marquee-track { animation: footer-marquee 44s linear infinite; }
@keyframes footer-marquee {
  from { transform: translate3d(0, 0, 0); }
  to { transform: translate3d(-50%, 0, 0); }
}

/* ── Reduced motion: hard stop ───────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  html.motion-ready .ticker-track,
  html.motion-ready .footer-marquee-track,
  html.motion-ready .hero-pill .dot { animation: none !important; }
  html.motion-ready .process-step { opacity: 1; transform: none; }
  html.motion-ready [data-tilt]::after { display: none; }
  .cursor, .cursor-label { display: none !important; }
}

@media (max-width: 768px) {
  html.motion-ready .process-track { display: none; }
}

/* ── View transitions ────────────────────────────────────────────────── */
@view-transition { navigation: auto; }

::view-transition-old(root),
::view-transition-new(root) { animation-duration: 0.5s; animation-timing-function: var(--ease-out); }

html.vt-theme::view-transition-old(root) { animation: none; }
html.vt-theme::view-transition-new(root) { animation-name: vt-reveal; }
@keyframes vt-reveal {
  from { clip-path: circle(0% at var(--vt-x, 50%) var(--vt-y, 50%)); }
  to { clip-path: circle(150% at var(--vt-x, 50%) var(--vt-y, 50%)); }
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}
docs/usage.md
# motion-kit · 用法

## 1 接入步骤

**① 引样式**(`<head>` 里,在宿主自己的样式之前或之后都可以,它只碰动效属性):

```html
<link rel="stylesheet" href="motion.css">
```

**② 挂启动类(同步、内联、必须在 `<head>`)** —— 这段决定"入场态在 JS 到齐前是否生效":

```html
<script>
  (function () {
    try {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      document.documentElement.classList.add('js-motion');
      window.setTimeout(function () {
        if (!document.documentElement.classList.contains('motion-ready')) {
          document.documentElement.classList.remove('js-motion');
        }
      }, 2500);
    } catch (e) {}
  })();
</script>
```

- 系统开了「减少动效」→ 什么都不挂,页面直接就是静态版。
- 脚本(③)正常跑完会补上 `motion-ready`;2500ms 内没补上(网络慢、脚本挂了)就摘掉 `js-motion`,入场元素恢复可见 —— **不会留白屏**。

**③ 引脚本**,页面末尾一次:

```html
<script src="motion.js" defer></script>
```

**④ 按第 3 节的对照表,给元素加标记。** 不加标记的模块不会启动;只加一个,就只有那一个在跑。

## 2 宿主 token 清单

`motion.css` 不自带配色,只读下面这些名字。宿主缺哪个补哪个(值随宿主主题走):

| token | 用在哪 |
|---|---|
| `--background` | 跑马灯底、footer 跑马灯分隔 |
| `--card` | 倾斜卡片的底(可选) |
| `--foreground` | 进度线、轨道、光标、粒子连线、斜体回正后的字 |
| `--muted` | 导航静息、步骤标题、折叠答案 |
| `--muted-soft` | 序号、次要字 |
| `--border-hairline` | 跑马灯上下线、轨道底线、折叠分隔 |
| `--border-control` | 跑马灯分隔点 |
| `--surface-hover` | 磁吸/倾斜控件的悬停底(可选) |

外加 `motion.css` 自己的三档缓动:`--ease-out` `--ease-in-out` `--ease-spring`(可在宿主覆盖)。

## 3 模块与标记对照

| 模块 | 需要的标记 / class | 行为与参数 |
|---|---|---|
| 入场 | `[data-reveal]` | `opacity 0 → 1`、`translateY 26px → 0`、`blur 6px → 0`;`--reveal-delay`(整数)×70ms |
| 错峰 | `[data-reveal-stagger]`(容器) | 给每个子元素自动补 `data-reveal` 与 `--reveal-delay=下标` |
| 逐字上浮 | `[data-split]` / `[data-split="chars"]` | 默认按空白切词;**中文 / 日文标题用 `data-split="chars"` 按码位逐字切**(空白切词对无空格语言只会切出一个 token)。每段外包 `.split-word`,子 span 从 112% 下方位移 + 2° 旋转回正;`--i`×55ms |
| 计数 | `[data-count="42"]` | 进视口后 900ms `easeOutCubic` 数到 42,只跑一次 |
| 倾斜 + 光斑 | `[data-tilt]` | 指针位移写 `--rx`(±6°)/`--ry`(±8°)/`--mx`/`--my`;`::after` 用后两个画径向光 |
| 磁吸 | `[data-magnetic]` | 相对中心偏移写 `--mx`/`--my`(±7px),离开即清 |
| 光标 + 标签 | `[data-cursor-label="文案"]` | 生成 `.cursor`(环+点,lerp 0.2)与 `.cursor-label`(lerp 0.14);`<html>` 上挂 `has-cursor`;命中 `a/button/input/textarea/select/summary/[data-tilt]` 时环放大 |
| 首屏场 | `[data-hero]` + `[data-hero-canvas]` + `[data-hero-glow]` | 2D 粒子连线;粒子数 `clamp(面积/21000, 26, 88)`,窄屏 ×0.62;连线 132px 内、指针 176px 内;`dpr` 上限 1.5;离开视口或切后台停帧 |
| 过程轨道 | `[data-process]` + `.process-track > span` + `.process-step` | 轨道 `scaleX` 跟滚动;步骤按进度点亮 `is-active` |
| 图片漂移 | `.work-card` > `.work-media img` | 按卡片中心到视口中心的距离写 `--py`(±14px) |
| 顶部进度线 | `.scroll-progress > span` | `scaleX` = 页面滚动比例 |
| 头部 / 导航 | `.site-header`、`.nav > a[href^="#"]` | 滚过 6px 加 `is-scrolled`;按视口 32% 探针点亮 `is-active` |
| FAQ 折叠 | `.faq-item`(`<details>`) > `summary` + `.faq-answer` | 打开前量 `scrollHeight` 再过渡(0.38s),关闭 0.32s;动画中屏蔽二次点击 |
| 跑马灯 | `.ticker` / `.footer-marquee` | 纯 CSS:36s / 44s 线性无限;`.ticker:hover` 暂停 |
| 明暗过场 | `window.motionKit.applyTheme` | 宿主把"应用主题"挂上去;调用 `window.motionKit.viewTheme('light' \| 'dark')` 走 View Transition 圆形揭示(不支持则直接应用) |

## 4 参数

| 变量 | 默认 | 说明 |
|---|---|---|
| `--ease-out` | `cubic-bezier(.22,1,.36,1)` | 入场/位移主缓动 |
| `--ease-in-out` | `cubic-bezier(.65,0,.35,1)` | 呼吸/脉冲 |
| `--ease-spring` | `cubic-bezier(.34,1.56,.64,1)` | 预留的过冲 |
| `--reveal-delay` | `0` | 入场错峰,单位 = 70ms |
| `--i` | `0` | 逐字错峰,单位 = 55ms |
| `--py` | `0px` | 图片漂移,脚本写 |
| `--rx` `--ry` `--mx` `--my` | — | 倾斜/磁吸,脚本写;别手写死值 |
| `--vt-x` `--vt-y` | `50%` | 主题揭示的圆心,脚本按按钮位置写 |

## 5 性能口径

- 所有滚动监听合并进**一条** rAF 帧(`onScrollFrame`);页面里再加滚动逻辑请复用 `requestScroll()` 的思路,别各自 `addEventListener('scroll')`。
- 入场优先 `IntersectionObserver`(`rootMargin 0 0 -10%`,`threshold .06`),并额外做一次视口预扫,首屏元素两条 rAF 后直接放行。
- 粒子场是唯一持续绘制的模块:粒子数上限 88、`dpr` 上限 1.5、离开视口或 `document.hidden` 停帧。**这三条不许放宽**。
- `will-change` 只加在入场与倾斜元素上,`.is-inview` 后立刻回收。

## 6 反模式

- ❌ 给元素手写 `opacity: 0` 当兜底 —— 无 JS 或减少动效的用户会永远看不见内容。入场态只由 `html.js-motion [data-reveal]` 给。
- ❌ 把 `js-motion` 放到 `<body>` 末尾或 `defer` 脚本里加:首帧已经画完,入场会从"突然消失再出现"开始闪一下。
- ❌ 同一页引两次 `motion.js`(会注册两套监听,粒子场变两台)。
- ❌ 在移动端也开 tilt / magnetic / cursor:脚本已按 `pointer: fine` 拦掉,别为了"平板也能看效果"去掉这条。
- ❌ 用 `max-height` 猜 FAQ 高度:答案换行一变就露馅,这里量的是 `scrollHeight`。
- ❌ 把 `.work-card` / `.faq-item` / `.site-header` 改成别的类名后不同步脚本 —— 模块会静默不启动(看起来像"没效果"),这是最容易被当成 bug 的一类。
- ❌ 需要"整页翻页"或"文字解散重聚"却用本包硬塞:那两件在 `page-transition` 里,本包只做页内。

## 7 配对

| 一起用 | 为什么 |
|---|---|
| `dual-theme-toggle` | 它给按钮与无闪切换,本包给切换那一刻的圆形揭示(`motionKit.viewTheme`) |
| `token-harbor` 或任一主题包 | 本包不定义配色,只读宿主 token;同名的 `--background` / `--foreground` 直接复用 |
| `nav-rail-marquee` | 若用角上的竖排跑马灯导航,关掉本包的 `.nav` 当前段高亮,避免两套"当前段"打架 |
| `page-transition` | 翻页过场归它,页内入场归本包;两者都挂时先确认 `view-transition` 不互相抢 root |

## 8 无障碍

- 减少动效:脚本顶部 `matchMedia('(prefers-reduced-motion: reduce)')` 命中即整体 `return`;CSS 末尾另有一块硬停(跑马灯、脉冲点、倾斜光斑、光标)。
- 光标是**叠加层**,不动原生指针;`aria-hidden="true"`。
- 入场只改 `opacity` / `transform` / `filter`,不改可访问性树;内容在 HTML 里,没有懒加载文案。
- 键盘用户不受影响:所有命中逻辑只挂 `pointermove`,焦点环用宿主的 `:focus-visible`。
SKILL.md
---
name: motion-kit
description: Add a dependency-free motion layer to long pages and landing pages — scroll reveal with blur, per-word headline rise, stagger groups, counters, card tilt with a pointer spotlight, magnetic controls, a custom cursor with contextual labels, a hero canvas particle field lit by the pointer, a scroll-linked process rail, image parallax, a top progress line, active-section nav highlighting, height-animated FAQ, and a circular View-Transition theme swap. Every module boots only when its markup exists; the whole layer disappears under prefers-reduced-motion and without JS.
---

# motion-kit · 动效层(十模块)

两个文件(`motion.css` + `motion.js`),一层动效:**滚动入场**、**逐字上浮**、**错峰**、**计数**、**倾斜+指针光斑**、**磁吸**、**自定义光标+上下文标签**、**首屏粒子场+指针光**、**过程轨道**、**图片漂移**、**顶部进度线**、**导航当前段**、**FAQ 高度折叠**、**明暗切换的圆形揭示**。

设计前提只有两条:**渐进增强**(没有 `js-motion` / `motion-ready` 类时,所有动效规则不命中,页面就是静态排版)与**按需启动**(每个模块都先找自己的标记,找不到就 `return`,不占帧)。

## When to use

- 长页 / 落地页 / 作品集要给"一层入场与指针反馈",但不想引 GSAP / Lenis / Motion One 之类运行时。
- 需要**只取其中几个模块**:页面上不放对应标记,其余模块完全不启动。
- 需要动效**在减少动效与无 JS 时整体消失**,而不是靠一堆 `@media` 打补丁。
- 需要首屏一块"轻"的动态场(2D 粒子连线,粒子数按面积夹在 26–88),而不是 WebGL 重背景。
- 站点已有自己的主题切换按钮,只想加"切换那一刻的圆形揭示"过场。

## When not to use

- 需要 3D / WebGL / 满屏重背景(走 `bg-fx`、`three-fx`)。
- 需要整页翻页过场,或文字解散后再聚成新字(走 `page-transition`)。
- 需要压在四角的竖排跑马灯导航(走 `nav-rail-marquee`)。
- 要的是「深浅两态按钮 + 无闪切换」本身(走 `dual-theme-toggle`;本包只做切换那一刻的 View Transition)。
- React / Vue 组件封装(这是无依赖经典脚本层,`<script defer>` 一条)。

## Files

| path | use |
|---|---|
| `src/motion.css` | 动效层全部样式:入场态、指针层、跑马灯、轨道、光标、View Transition。只依赖宿主的颜色 token(见 `docs/usage.md` §2) |
| `src/motion.js` | 无依赖经典脚本:共享滚动帧、IntersectionObserver 入场、倾斜/磁吸/光标、粒子场、轨道、漂移、进度线、导航当前段、FAQ、主题过场 |
| `preview/index.html` | 舞台页:首屏场 + 跑马灯 + 计数 + 轨道 + 倾斜/磁吸/光标 + 漂移卡 + 折叠 + 明暗过场,双击即开 |
| `docs/usage.md` | 接入步骤、宿主 token 清单、模块与标记对照表、参数、性能口径、反模式 |
| `scripts/assert_tokens.py` | 自检:元数据 / 声明路径 / 预览引用解析 / 产物非空 / 关键实现标记 / 无站点专有标识 |

## Agent apply steps

1. 读 `docs/usage.md` §2 的宿主 token 清单(`--background` `--foreground` `--muted` `--muted-soft` `--border-hairline` `--border-control` `--surface-hover` `--card`),确认项目里已经有;没有就补一份 `:root`。
2. 把 `src/motion.css` 与 `src/motion.js` 拷进项目静态资源目录,页面 `<head>` 引 CSS。
3. 在 `<head>` 里**(同步、内联)**放启动脚本:非 `prefers-reduced-motion` 时给 `<html>` 挂 `js-motion`,并在 2500ms 后若仍无 `motion-ready` 则摘掉 —— 这段是防"入场态卡死"的兜底,`docs/usage.md` §1 有可直接抄的 6 行。
4. 按 `docs/usage.md` §3 的对照表给元素加标记(`data-reveal` / `data-split` / `data-count` / `data-tilt` / `data-magnetic` / `data-cursor-label` / `data-hero*` / `data-process`),宿主 class(`.site-header` `.nav` `.work-card` `.faq-item` `.ticker` `.scroll-progress` 等)按表保留原名。
5. `<script src="motion.js" defer></script>` 放一次即可,不要重复引入。
6. 宿主若有主题切换,把"应用主题"的函数挂到 `window.motionKit.applyTheme`,并在切换按钮里改调 `window.motionKit.viewTheme(next)`(没有该函数时脚本会自己降级,不报错)。
7. 自检:`python packs/motion-kit/scripts/assert_tokens.py`;页面侧验四件事 —— 关 JS 内容全可见、系统「减少动效」时无位移无跑马灯、Tab 焦点环可见、首屏场在切后台/离开视口时停帧。

## Hard rules

- **不许改标记名与宿主 class 名**:`motion.js` 按 `data-*` 与 `.site-header` / `.nav` / `.work-card` / `.work-media img` / `.faq-item` / `.process-track` / `.scroll-progress` 找目标,改名必须同步改脚本。
- **`js-motion` 必须在 `<head>` 同步挂上**,且必须带 2500ms 兜底;否则入场元素会永久停在 `opacity: 0`。
- **`prefers-reduced-motion: reduce` 是硬停**:`motion.css` 末尾那块不许删;脚本在文件顶部就直接 `return`(不注册任何监听)。
- 指针类模块(tilt / magnetic / cursor)只在 `pointer: fine` 下启用,别改成"有指针就上"。
- 粒子场帧循环必须在**页面隐藏或离开视口时停帧**;`devicePixelRatio` 上限 1.5,粒子数上限 88,不许为了"更密"抬这两个数。
- 中文 / 日文标题要逐字上浮时用 `data-split="chars"`(按码位切);默认的空白切词对无空格语言只会切出一个 token,看起来像"没效果"。
- 入场元素不要手写 `opacity: 0` 兜底 —— 入场态由 `html.js-motion [data-reveal]` 统一给,手写会绕过无 JS 降级。
- FAQ 用原生 `<details>/<summary>`,高度用 `scrollHeight` 量出来再过渡,不许写死 `max-height` 猜值。
- 同一页只引一次脚本;`init*` 都在 `boot()` 里跑,不要自己再调一遍。
- 消费后必须在项目 `design-lock.json` 记 `assets[]`(`pack` / `version` / `sha`)。

← 回索引