Communitygithub.com

karero/website-motion

兩種設計效果,專為實際長頁面打造。動畫是網站開始顯得機械化的地方,而這個技能提供了兩種真正值得加入的效果——更重要的是,一套讓它們安全無虞的合約。

website-motion 是什麼?

這個技能為靜態網站添加了兩種經過審慎考慮的動畫效果:滾動揭露(僅限章節標題)和英雄區統計數字計數動畫。它強制執行一項嚴格合約:禁止任何效果讓內容變得無法存取。無障礙合規性(a11y)是核心考量:揭露效果在 `opacity: 0` 時會導致 axe 跳過這些元素,因此這個技能會修改 Playwright 測試套件,在執行無障礙檢查前強制啟用「減少動畫」偏好設定,並加入一個監測來驗證該設定確實有傳遞到瀏覽器。它也處理列印和減少動畫等邊緣情況,確保內容在所有狀態下都可見。此技能支援任何靜態 HTML/Astro 網站,並附帶全面的 Playwright 測試(motion.spec.ts),以驗證合約的所有面向。

相容平台~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;
  }
  /* 與上方規則相同 specificity,且在樣式表中 LATER(更晚),所以那裡的簡寫
     無法將延遲重置為零。 */
  .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();
    // 數字、可選的分組分隔符(英文為 ",",德文為 ".")、可選的尾綴符號。
    // 其他所有內容都保持與撰寫時完全相同:這些是已發布的宣稱,而 "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] || '';

    // 鏡像 AUTHORED(已撰寫的)分組方式,而不是執行時的地區設定。下面的凍結
    // 寬度是根據已撰寫的字串測量的,因此渲染 "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 本身會產生預期效果環境動畫忽略偏好設定;平滑滾動被保留;覆寫被放在它覆寫的規則之前 — 相同 specificity,所以原始規則獲勝,修復是靜默的無作用
統計數字穩定在它們已發布的值上有人在 HTML 中編輯了數字

第 4 行的強制類別步驟並非雙重保險。在減少動畫下,揭露的 JS 會退出,所以隱藏類別永遠不會被加入,樣式表也永遠不會被執行——如果不強制啟用它,那行測試會在 CSS 覆寫損壞的情況下通過。

「沒有任何東西在動畫」的測試也必須涵蓋頁面的其他動畫——例如環境的英雄區光暈、scroll-behavior: smooth。只斷言你自己的效果會讓測試名稱變成謊言。

計數動畫的斷言是內容快照,而不是動畫測試。 最後一幀會恢復已撰寫的字串,所以它在動畫失效時也會通過。在註解中說明這一點,而不是暗示它擁有實際上沒有的涵蓋範圍;從測試端對 1.4 秒的 rAF 動畫進行取樣是時間依賴性的,不值得為了一個不穩定的檢查。


6. 真正耗費除錯時間的陷阱

  • 減少動畫的覆寫必須放在它覆寫的規則之後。 相同 specificity 意味著後者獲勝。放在原始宣告旁邊看起來更自然,但卻完全不作用。
  • 跑馬燈風格的條帶無法被 Playwright 懸停或點擊。 它會等待元素停止移動,而循環的元素永遠不會停止。改為通過座標(page.mouse.move)驅動滑鼠。
  • 對循環動畫進行取樣會在循環點處不穩定。 單一的前後配對會測量一個視窗對循環週期的比例——對一個 90 秒循環進行 1 秒取樣,大約每九十次執行會落在重新啟動點上,那裡位置會向前跳動。取樣幾個間隔並取多數結果;其中只有一個會包含循環點。
  • 加入一個標題 c

Individual skills in this repo

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

相關技能