ウェブサイトモーション
2つのエフェクト。意図的に絞り込んでいます。モーションはサイトが機械的に見え始めるポイントです。すべてのカードや段落に適用するとテンプレートのように読めてしまい、誰も気づかないアクセシビリティバグによってコンテンツを失う最も簡単な方法になります。このスキルは、その価値がある2つのエフェクトと、さらに重要なこととして、それらを安全にするための契約を提供します。
ページが実際に長い場合にのみ追加してください。 3セクションしかないサイトでは、リビールはノイズにしかなりません。カウントアップは、訪問者が立ち止まって読むべき数字(参加者数、開催年数、リリースしたプロジェクト数)に必要であり、単なる装飾ではありません。
前提条件: 先に website-qa がグリーンであること。このスキルはそのスイートにテストファイルを追加し、a11yゲートの動作を変更します。
以下のクラス名は例であり、スキャフォールドのものではありません
.section-title、.section-subtitle、.hero-stat-number、.heroはnew-websiteの Astro テンプレートには 一切 存在しません。これらは実際のサイトから抽出されたものです。セレクタを自身のマークアップに合わせて設定するまで、何も機能しません。CSS、2つのスクリプト、そして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. 契約
以下のすべては、一つの約束を守るためにあります。いかなるエフェクトも、コンテンツにアクセスできなくしてはなりません。 優先順位順に3つのルールです。
-
非表示状態は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;
}
/* 上記ルールと同じ詳細度でスタイルシート内で後にあるため、
そこのショートハンドがディレイをゼロにリセットすることはありません。 */
.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] || '';
// 実行時のロケールではなく、作成されたグループ化をミラーリングします。
// 以下の固定幅は作成された文字列から測定されるため、「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 で reduced motion を強制し、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),
'reduced motion がブラウザに到達しませんでした — a11yゲートは決定論的ではありません')
.toBe(true);
5. motion.spec.ts — 契約を現実にするテスト
このスキルの templates/motion.spec.ts をサイトの tests/ にコピーしてください。これは、意図的に new-website の Astro オーバーレイではなく、ここにあります。そのオーバーレイはすべてのスキャフォールドにコピーされ、このスペックは新しいサイトにはないエフェクトをアサートします — すべての新しいプロジェクトの初日に失敗することになります。
以下の各行は、契約が破られる特定の方法のために存在します。右の列は、そのアサーションが何をするためのものであり、それがどのくらいの頻度で発火したかについての主張ではありません。
| アサーション | 捕捉するバグ |
|---|---|
| 完全にスクロールしても非表示の見出しがない | このスキルが防御する全体的な障害モード |
| フォールドより上のものはロード時に非表示にならない | 見出しが視覚的にフラッシュアウトしてフェードバックインする |
リビールターゲットは media: print で表示される | 印刷オーバーライドが削除または順序を間違えている |
reduced motion では何も非表示にならず、scroll-behavior は auto、何もアニメーションしておらず、.reveal ルールが強制適用されてCSS自体が機能することを証明する | 設定を無視するアンビエントアニメーション;スムーズスクロールがオンのまま;そしてオーバーライドがオーバーライドするルールの 前に 配置されている — 同じ詳細度のため、元のルールが勝ち、修正は静かなno-opになる |
| 統計数値は公開された値で確定する | HTML内の数字を誰かが編集した |
行4の強制クラスステップは過剰な防御ではありません。reduced motion では、リビールのJSは実行を中止するため、非表示クラスは追加されず、スタイルシートは実行されません — これを強制しなければ、その行はCSSオーバーライドが壊れていてもパスします。
「何もアニメーションしない」テストは、ページの他のアニメーションもカバーする必要があります — アンビエントなヒーローのグロー、scroll-behavior: smooth。自身のエフェクトのみをアサートすると、テストの名前は嘘になります。
カウントアップのアサーションはコンテンツスナップショットであり、アニメーションテストではありません。 最終フレームは作成された文字列を復元するため、アニメーションが死んでいてもパスします。コメントでそのように言及し、それが持っていないカバレッジを暗示しないようにしてください。テスト側から1.4秒のrAFアニメーションをサンプリングすることはタイミングに依存し、不安定なゲートの価値はありません。
6. 実際のデバッグ時間を浪費する落とし穴
- reduced motion オーバーライドは、オーバーライドするルールの 後 に来なければなりません。 同じ詳細度の場合、後で書かれたものが優先されます。元の宣言の隣に置くとより自然に読めますが、何も効果がありません。
- マーキー形式のストリップは、Playwright でホバーまたはクリックできません。 要素が動きを止めるのを待ちますが、ループする要素は決して止まりません。代わりに座標でマウスを動かしてください(
page.mouse.move)。 - ループするアニメーションのサンプリングは、ラップポイントで不安定になります。 単一の前後ペアは、ループ周期に対して一つのウィンドウを測定します — 90秒のループの1秒のサンプルは、再開点に当たります。そこでは位置が前方にジャンプします。約90回に1回の実行です。複数の間隔をサンプリングして多数決を取ってください。ラップを含むことができるのはそのうちの1つだけです。
- 見出しを追加する
…(原文が途中で切れているため、信頼できる翻訳を提供できません。)