Communitygithub.com

karero/website-motion

两个精心挑选的动效。动效是网站开始显得模板化的标志:此技能提供了两个值得使用的效果,以及确保它们安全的契约。

website-motion 是什么?

此技能为网站提供两种经过精心挑选的动效:滚动显示(Scroll Reveal)和统计数据数字滚动(Hero Stat Count-Up)。与常见的动效库不同,它遵循严格的契约,确保动效不会导致内容不可访问。其核心原则包括:隐藏状态由JavaScript添加而非HTML原生编写;尊重用户的“减少动效”偏好;以及处理打印状态。滚动显示仅应用于章节标题,避免在卡片或段落上滥用。数字滚动效果允许数字以动画形式增长,但最终值始终保留在HTML中,确保无JS用户和爬虫可见。此外,该技能还包含一个测试文件`motion.spec.ts`,用于验证这些动效不会破坏可访问性,并强制修改a11y测试配置,以防止因动效隐藏元素而导致颜色对比度检查被跳过。该技能适用于Astro项目(如`new-website`脚手架),但支持通过配置选择器适配任何网站。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/karero/croftweaver/tree/HEAD/skills/website-motion

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

网站动效

两个精心挑选的动效。动效是网站开始显得模板化的标志:应用于每个卡片和段落,它就像读取模板一样,也是因无人注意的可访问性错误而丢失内容的最简单方式。此技能提供了两个值得拥有的效果,更重要的是,提供了使它们安全的契约。

仅在页面实际很长时添加。 在一个只有三个部分的网站上,揭示效果只是噪音。数字滚动需要的是访问者应该停下来阅读的数字(与会者人数、运营年数、交付的项目),而不是装饰。

前置条件: website-qa 首先为绿色。这会向该套件添加一个测试文件,并更改 a11y 检查的运行方式。

下面的类名是示例,而非脚手架的

.section-title, .section-subtitle, .hero-stat-number 和 .hero 在 new-website 的 Astro 模板中出现零次。它们来自一个从中提取此功能的真实网站。在你将每个选择器指向你自己的标记之前,这里没有任何东西能正常工作——CSS、两个脚本以及 templates/motion.spec.ts 顶部的 CONFIG 块。当选择器留空时,规范会附带说明而跳过,因此未配置的副本会报告“不适用”,而不会以看起来像产品错误的方式失败。


0. 不是动效的一行检查

在添加任何内容之前,先测量英雄区域:

const h = document.querySelector('.hero');           // 你的英雄区域选择器
({ vh: innerHeight,
   heroHeight: h?.getBoundingClientRect().height ?? null,
   nextSectionVisible: h?.nextElementSibling
     ? innerHeight - h.nextElementSibling.getBoundingClientRect().top : null })

如果 min-height: 100vh 是绑定的 —— 英雄区域自身内容短于视口 —— 下一个部分正好从折页开始,页面读起来像闪屏。缩小高度直到下一个部分露出边缘。

不要从其他网站复制数字。 正确的值是英雄区域内容高度的函数。如果内容已经超过视口,更改 min-height 在该屏幕尺寸上根本不起作用,真正的杠杆是从英雄区域中删减一个段落。测量,然后决定。


1. 契约

以下所有内容的存在是为了兑现一个承诺:没有动效可以导致内容无法访问。 三条规则,按优先级排序。

  1. 隐藏状态由 JS 添加,绝不在 HTML 中编写。 没有 JS 的访问者,或从页面中间进入的访问者,必须看到完成的页面。脚本自行添加隐藏类,并且仅添加到当前在折页下方的元素。

  2. prefers-reduced-motion: reduce 最终使所有内容可见且无任何动效 —— 包括早于此技能存在的动画。Astro 脚手架默认带有 html { scroll-behavior: smooth } 且无覆盖,因此这是你必须实际执行的步骤,而非继承的属性:

    /* 在其覆盖的规则之后放置——参见 §6,这是整个陷阱 */
    @media (prefers-reduced-motion: reduce) {
      html { scroll-behavior: auto; }
      /* 加上此网站运行的任何环境动画 */
    }
    
  3. 打印是第三种状态。 它从不滚动,因此 IntersectionObserver 从不触发,已揭示的标题会打印为空白。


2. 滚动揭示——仅章节标题

@media (prefers-reduced-motion: no-preference) {
  /* 隐藏状态无过渡。`reveal` 在首次绘制后添加,因此这里的过渡会使已经绘制好的标题在淡入前先淡出半秒钟——任何加载后立即滚动的用户都能看到。
     将过渡放在可见状态上,隐藏会瞬间完成,只有揭示会产生动画。 */
  .reveal { opacity: 0; transform: translateY(12px); }
  .reveal.is-visible {
    opacity: 1; transform: none;
    transition: opacity 0.55s ease-out, transform 0.55s ease-out;
  }
  /* 与上面规则相同的特异性,且在样式表中更靠后,因此那里的简写无法将延迟重置为零。 */
  .reveal-delay.is-visible { transition-delay: 0.09s; }
}
/* 打印从不滚动,因此观察者从不触发。 */
@media print { .reveal { opacity: 1 !important; transform: none !important; } }
(function () {
  if (!('IntersectionObserver' in window)) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  function partnerOf(heading) {
    var next = heading.nextElementSibling;
    return next && next.matches('.section-subtitle') ? next : null;
  }
  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (!entry.isIntersecting) return;
      io.unobserve(entry.target);
      entry.target.classList.add('is-visible');
      var partner = partnerOf(entry.target);
      if (partner) partner.classList.add('is-visible');
    });
  }, { threshold: 0.15 });

  document.querySelectorAll('.section-title').forEach(function (heading) {
    // 已经在屏幕上的东西保持不变——现在隐藏它会导致它在加载时闪烁消失再淡入。
    if (heading.getBoundingClientRect().top < window.innerHeight) return;
    heading.classList.add('reveal');
    var partner = partnerOf(heading);
    if (partner) partner.classList.add('reveal', 'reveal-delay');
    io.observe(heading);
  });
})();

仅标题。 副标题会慢一拍跟随,以使这对标题读起来像一个动作——这就是“交错”在这里应该意味着的含义。将此项应用于卡片、列表项和段落是 AI 生成网站的标志。


3. 英雄区域统计数据数字滚动

(function () {
  var nums = document.querySelectorAll('.hero-stat-number');   // 你的选择器
  if (!nums.length || !('IntersectionObserver' in window)) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  function countUp(el) {
    // 写入 textContent 会永久扁平化子标记——数字内带样式的 <span>+</span>
    // 无法在此动画中存活。
    if (el.children.length) return;

    var finalText = el.textContent.trim();
    // 数字、可选的组分隔符(en 中为 ",",de 中为 ".")、可选的后缀符号。
    // 其他所有内容完全按原样保留:这些是已发布的声明,"4.9" 绝不能动画到 49。
    var parts = finalText.match(/^(\d{1,3}(?:([.,])\d{3})*|\d+)(\D*)$/);
    if (!parts) return;
    var target = parseInt(parts[1].replace(/\D/g, ''), 10);
    var groupSep = parts[2] || '';
    var suffix = parts[3] || '';

    // 镜像作者的分组,而非运行时的区域设置。下面的冻结宽度是从作者字符串测量的,
    // 因此渲染 "1,200" 如果页面显示 "1.200"——或者分组了页面未分组的数字——
    // 会导致宽度不同,而这正是冻结要防止的行跳动。
    function render(v) {
      var digits = String(v);
      return (groupSep ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, groupSep) : digits) + suffix;
    }

    // 首先冻结盒子。"0+" 比 "1,200+" 窄得多,而统计数据通常由数字而非标签决定大小,
    // 因此未冻结的计数会在每一帧将整行推向一侧。
    // 记住元素上已有的任何内联 min-width——在稳定时清空它会悄悄地撤销其他人后来做出的样式更改。
    var priorMinWidth = el.style.minWidth;
    el.style.minWidth = el.getBoundingClientRect().width + 'px';
    var done = false;
    function settle() {
      done = true;
      el.textContent = finalText;
      el.style.minWidth = priorMinWidth;
    }
    // 在计数过程中触发的打印会捕获一个中间数值——一个网站未声明的数字。
    // 契约规则 3 涵盖了揭示效果;这是数字滚动的一半。
    window.addEventListener('beforeprint', settle);

    var started = performance.now();
    function frame(now) {
      if (done) return;
      var t = Math.min((now - started) / 1400, 1);
      el.textContent = render(Math.round(target * (1 - Math.pow(1 - t, 3))));
      if (t < 1) requestAnimationFrame(frame);
      else settle();
    }
    requestAnimationFrame(frame);
  }

  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (!entry.isIntersecting) return;
      io.unobserve(entry.target);
      countUp(entry.target);
    });
  }, { threshold: 0.6 });
  nums.forEach(function (n) { io.observe(n); });
})();

最终值保留在 HTML 中,因此无 JS 访问者和爬虫看到真实数字,动画只临时替换它们。

min-width 仅在块级盒子上有效。 如果统计数字计算为 display: inline,则冻结是静默的无操作,行仍然跳动。检查计算值,或设置 display: inline-block。

在 DOM 解析后运行两个脚本 —— 使用 defer、模块标签或放在 <body> 末尾。在同步的 <head> 脚本中,查询返回空,两个效果都静默失效。

4. a11y 检查必须更改,否则它会悄然变弱

这是容易遗漏的部分,也是此技能存在(而非 website-design-system 中的片段)的原因。

揭示效果使标题在 axe 运行时处于 opacity: 0 状态,而 axe 会跳过它认为不可见的元素。 它们的颜色对比度悄然不再被检查。套件保持绿色,而检查比前一天更弱。

在 a11y.spec.ts 中强制使用减少动效,以便 axe 始终看到完成的页面。下面的代码片段假设该文件是 for (const path of PAGES) 这种形式——请根据你套件实际使用的循环或装置进行调整。goto 之前的 emulateMedia 调用及其下方的验证是承载关键的部分:

for (const path of PAGES) {
  test(`a11y: ${path}`, async ({ page }) => {
    await page.emulateMedia({ reducedMotion: 'reduce' });
    await page.goto(path, { waitUntil: 'load' });
    // …其余不变

test.use({ reducedMotion: 'reduce' }) 可能不是替代方案。 测量过一次,在 Playwright 1.60 上,针对不同仓库的配置 —— 而非此工具包的配置,其锁文件当前解析为 1.64.1。在那次测量中,test.use 形式被接受且无报错,但页面的 matchMedia 仍报告为 no-preference;而同一运行中 page.emulateMedia 和 browser.newContext 都工作正常。这与该选项的文档说明相矛盾,且机制从未被证实,因此将其视为一次测量而非关于 Playwright 的规则。这里使用 emulateMedia 是因为它是实际观察到有效的形式。

无论你使用哪种形式,证明它已到达浏览器,因为失败是静默的——而且每次测试调用很容易因一行代码的遗漏而导致页面悄然保持弱化的检查:

// 验证。没有它,一个什么都不做的检查看起来和真正的检查一模一样。
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches),
  '减少动效从未到达浏览器——a11y 检查不是确定性的')
  .toBe(true);

5. motion.spec.ts —— 使契约成为现实的测试

将此技能的 templates/motion.spec.ts 复制到网站的 tests/ 目录中。它有意放在此处,而非 new-website Astro 覆盖层中:该覆盖层会被复制到每个脚手架中,而此规范断言的是新网站没有的效果——它会在每个新项目的第一天就失败。

下面的每一行都对应于契约被破坏的特定方式。右侧列是断言的目的,而非关于它被触发了多少次的声明:

断言它捕获的错误
完整滚动后没有标题不可见此技能防范的整个失败模式
折页上方在加载时无任何隐藏内容标题明显闪烁消失并淡回
在 media: print 下揭示目标可见打印覆盖被删除或顺序错误
在减少动效下无内容隐藏,scroll-behavior 为 auto,无任何动效,并且强制应用 .reveal 规则以证明 CSS 本身有效环境动画忽略偏好;平滑滚动未关闭;以及覆盖放置在 其覆盖的规则之前 —— 相同特异性,因此原始规则获胜,修复是静默的无操作
统计数字稳定在其发布的值有人在 HTML 中编辑了数字

第 4 行中的强制类步骤并非双重保险。在减少动效下,揭示的 JS 会退出,因此隐藏类从未添加,样式表也从未被使用——如果不强制应用它,该行会在 CSS 覆盖损坏的情况下通过。

“无动画”测试也必须覆盖页面的其他动画 —— 环境英雄区域发光、scroll-behavior: smooth。仅断言你自己的效果会使测试名称成为谎言。

数字滚动断言是内容快照,而非动画测试。 最终帧恢复作者字符串,因此在动画死亡时它也能通过。在注释中说明这一点,而非暗示它不具备的覆盖率;从测试端采样 1.4 秒的 rAF 动画是时间相关的,不值得一个不稳定的检查。


6. 实际花费过调试时间的陷阱

  • 减少动效覆盖必须放在它覆盖的规则之后。 相同特异性意味着后者优先。放在原始声明旁边读起来更自然,但不起作用。
  • 跑马灯风格的条带无法被 Playwright 悬停或点击。 它等待元素停止移动,而循环元素永远不会。改用坐标驱动鼠标(page.mouse.move)。
  • 采样循环动画会在环绕点导致不稳定。 单个前后对比对测量一个窗口与循环周期——90秒循环的1秒样本大约每九十次运行会落在重新开始点上,此时位置向前跳跃。采样多个间隔并取多数;其中只能有一个包含环绕点。
  • **添加一个标题...

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

相关技能