網站動畫
兩種效果,經過審慎考慮。動畫是網站開始顯得機械化的地方: 應用到每一個卡片和段落時,它就像一個模板,也是讓內容因無障礙錯誤而遺失最簡單的方式,而且沒人注意到。這個技能提供了兩種真正值得加入的效果,更重要的是——讓它們安全無虞的合約。
只有在頁面實際上很長時才加入。 在一個只有三個區塊的網站上,揭露效果只是雜訊。計數動畫需要訪客應該停下來閱讀的數字(參與者人數、運作年數、專案數量),而不是裝飾。
前置需求: 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. 合約
以下所有內容的存在都是為了遵守一個承諾:沒有任何效果會讓內容變得無法存取。 三條規則,按優先順序排列。
-
隱藏狀態由 JS 加入,永遠不會在 HTML 中撰寫。 沒有 JS 的訪客,或是中途進入頁面的訪客,必須看到完整的頁面。腳本會自行加入隱藏類別,而且只對目前低於摺頁的元素加入。
-
啟用
prefers-reduced-motion: reduce時,最終所有東西都可見且沒有任何東西在動——包括比這個技能更早存在的動畫。Astro 套件預設帶有html { scroll-behavior: smooth }且沒有覆寫,因此這是一個你實際上必須執行的步驟,而不是你繼承的屬性:/* 放在它覆寫的規則之後 — 參見 §6,這是整個陷阱 */ @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } /* 加上這個網站執行的任何環境動畫 */ } -
列印是第三種狀態。 它永遠不會滾動,所以
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