order: -1 這一行 CSS 會把元素搬到視覺上的第一個,但不會把它搬到 Tab 序的第一個——這就是昨天結尾那個洞的技術本體。昨天把焦點管理做成三個 Playwright 測試後,留了一句「仍須人工走查邏輯順序是否符合視覺順序」。今天處理這句話:兩種順序為什麼會分離、WCAG 用哪兩條準則各管一半、reading-flow 到 2026 年 9 月能不能真的用、以及那句「人工走查」有多少可以壓縮成一份二十行的清單。

📖 學

兩條準則,兩種順序,都在 Level A

先把條號釘死。SC 1.3.2 Meaningful Sequence 是 Level A:「當內容的順序會影響意義時,必須有一個正確的閱讀序列可以被程式化決定」,對象是全部內容,包含純文字。SC 2.4.3 Focus Order 也是 Level A:「可聚焦元件接收焦點的順序要保留意義與可操作性」,對象只有互動元素。兩條都是 A 級——不是加分項,是最低門檻。(承昨天引錯條號的教訓,兩條都回 W3C 原文對過。)

接下來是這題最大的反直覺點,而且很多教學寫錯:2.4.3 並沒有要求焦點順序等於視覺順序。 W3C 的 Understanding 文件講得很白:焦點順序不一定要跟隨視覺呈現或版面,只要順序合乎邏輯、視覺呈現所暗示的階層與關係被保留就行。它甚至舉了雙欄的例子——兩欄彼此獨立、意義與操作不受影響時,右欄先拿到焦點再輪到左欄不算失敗。文件只把「焦點順序呼應視覺閱讀順序」列為 best practice。

那什麼才算真的失敗?W3C 列了兩個 failure:F44 是用 tabindex 造出不保留意義與可操作性的 tab 序,F85 是對話框或選單在循序導覽順序上不緊鄰它的觸發控制項。1.3.2 那邊的對應是 F1:用 CSS 定位改變了內容的意義。而兩條準則共用同一個充分技術:C27 Making the DOM order match the visual order

造成分離的手法,各自動了哪一種順序

手法視覺順序Tab 焦點順序報讀器閱讀順序主要風險
order: -1(flex/grid item)不變(DOM 序)不變2.4.3 + 1.3.2
flex-direction: row-reverse不變不變2.4.3 + 1.3.2
grid 明確 grid-row / grid-column 放置不變不變2.4.3 + 1.3.2
position: absolute 搬到版面另一處不變不變1.3.2(F1)
正整數 tabindex="1"不變(而且是全域)不變2.4.3(F44),兩序更分裂
reading-flow / reading-order不變變(僅 Chromium)變(僅 Chromium)支援度
直接改 DOM 順序無(C27)

這張表只有最後一列讓三種順序同步移動;其他每一列都在製造分裂,差別只在分裂哪一邊。Flexbox 規格自己就警告過,大意是作者不得把 orderflex-flow*-reverse 值當成正確 source order 的替代品,那會毀掉文件的無障礙性。規格早就這樣寫,但 flex 與 grid 普及後反而更常見——「用 CSS 調位置」遠比「重排模板」便宜。

響應式斷點:一份 DOM 對上三種視覺順序

這是最無解的一種。DOM 只有一份,但三個斷點可能有三種視覺順序:手機上「摘要卡片」在標題底下,桌機上它被搬到右側 sidebar 最上面。這時候「讓 DOM 順序等於視覺順序」是數學上不可能的目標。

正確的思路不是追某個斷點的像素對齊,而是回到 1.3.2 的定義:只需要提供「一個」意義正確的順序,而且只有在順序會影響意義時才需要。 所以決策是——選一個在所有斷點下都合乎意義的 DOM 順序(通常是行動版那個,因為它單欄、線性、沒有選擇餘地),寬螢幕再用 grid 明確定位佈局。剩下的問題退化成 2.4.3:寬螢幕下 Tab 序不等於視覺序的那幾處,會不會「妨礙意義或操作」。獨立側欄不會,穿插在表單欄位之間的送出按鈕會。

順帶更正一個常見說法:「行動優先所以 DOM 順序照手機排就一定安全」——不成立。反例是導覽列:手機上折疊成漢堡按鈕,桌機上展開成八個連結橫在頁首,桌機使用者要先走完八個連結才碰到內容。這是 2.4.3 沒違反但體驗很差的典型,正解是 skip link,不是改 DOM 順序。

reading-flowreading-order:能做什麼,2026 年 9 月能不能用

規格在 CSS Display Level 4,設計目的正是上面這個矛盾:讓焦點與無障礙曝光的順序跟上視覺順序,而不必動 DOM。Chrome 137 出貨(2025 年 5 月底進 stable)。

reading-flow 下在容器上,預設 normal(維持 DOM 序)。flex 容器用 flex-visual(跟隨視覺,含 order 的效果)或 flex-flow(跟隨 flex 流向,即 row-reverse 下的反序);grid 容器用 grid-rowsgrid-columnsgrid-order;還有一個 source-order,flex/grid/block 都能用,作用是「不改順序,但讓容器成為 reading flow 容器」。

reading-order 下在個別項目上,只在容器的 reading-flow 不是 normal 時生效,而且會覆蓋 reading-flow 的結果(等於在它之後套用)。它的數字語意跟 order 一樣是權重不是位置reading-order: 345 不代表第 345 個,只代表比 0 晚、比 456 早;預設 0,同值者按 source order。這裡有個很容易踩的不對稱:在 flex-direction: row-reverse 下,要把某項拉到視覺最前面要用 order: 1(因為視覺方向被反轉了),但要把它拉到焦點最前面要用 reading-order: -1——reading-order 不吃反向。

邊界要講清楚,這是最容易被誤解成萬靈丹的地方:

  • 它不影響視覺。 只影響循序焦點導覽的順序與元素曝光給無障礙工具的順序;想改視覺還是得用 order / grid 定位。
  • 它把容器變成 focus scope owner。 循序導覽會先走完容器裡的所有元素才移到下一個可聚焦元素;容器的直接子元素上的正整數 tabindex 在排序上會被忽略(後代元素上仍然有效)。這是刻意設計,為了不再重演 tabindex 那套全域排序的災難。
  • display: contents 是已知的未定區。 這種元素會從佈局父層繼承 reading-flow,因此自己也成為合法的 reading flow 容器。Chrome 團隊專門發過一篇徵求回饋的文章,明說這塊行為仍在演進、可能改。元件庫裡對 display: contents 包裝層用 reading-flow,是目前最不該碰的組合。
  • 報讀器那端要實測。 Chrome 說「曝光給無障礙工具的順序」會改,但特定報讀器的虛擬游標(virtual cursor)會不會照著唸,我沒查到公開的逐組合測試報告——這一點已標存疑,要用就自己拿 NVDA/VoiceOver 走一遍。

最關鍵的是:2026 年 9 月的實際可用性是「Chromium 限定,不是 Baseline」。 三個獨立證據:

  1. Mozilla 的 standards-positions issue #1056 是 2024 年 7 月 31 日開的,指派給 emilio 與 jcsteh、掛了 Accessibility 與 Layout 兩個 team 標籤,狀態至今仍是 Needs proposed position——開了兩年多,Mozilla 連立場都還沒表態。
  2. WebKit 的 standards-positions issue #378 同樣沒有結論。
  3. reading-flow 在 2025 年 9 月被提案為 Interop 2026 的 focus area(issue #1130),沒有入選。Interop 2026 最終的二十個 focus areas(container style queries、anchor positioning、contrast-color()、dialogs and popovers、view transitions、scroll snap 等)裡沒有它;跟無障礙有關的只有列在 investigation efforts 的 Accessibility testing,目標是「讓相同的 DOM 與 CSS 在各瀏覽器產生一致的 accessibility tree」——那是 reading-flow 這類功能能被跨瀏覽器驗證的前置工程。

所以務實的定位是:reading-flow漸進增強,不是修法。DOM 順序必須在沒有它的情況下就已經合乎 1.3.2 與 2.4.3,然後才用它替 Chromium 使用者再拉好一級。

/* 卡片牆:DOM 順序本身就是意義正確的順序(時間新到舊), */
/* grid 只負責視覺配置。reading-flow 是額外的甜頭,不是靠它合規。 */
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
 
.card-grid > .featured {
  grid-column: span 2;
  grid-row: span 2;
}
 
@supports (reading-flow: grid-rows) {
  .card-grid { reading-flow: grid-rows; }
}

tabindex 正整數:為什麼幾乎永遠是錯的答案

Chrome 的 reading-flow 公告裡有一段值得抄下來的對照。要用舊工具達到 reading-flow: flex-visual 的效果,得寫成這樣:

<!-- 不要這樣做。這是「舊方法」的示範,不是建議。 -->
<div class="box" aria-owns="one three two">
  <a href="#" tabindex="1" id="one">One</a>
  <a href="#" tabindex="3" id="two">Two</a>
  <a href="#" tabindex="2" id="three">Three</a>
</div>

為什麼要多一個 aria-owns?因為正整數 tabindex 造出來的焦點順序不被 accessibility tree 認識——它只改鍵盤 Tab 序,沒改報讀器讀到的順序,所以你得再用 aria-owns 手動宣告一次。這正是昨天「語意軸 vs 操作軸」在這題的具體體現:正整數 tabindex 只動操作軸,把兩軸拉得更開。

更致命的是作用域。正整數 tabindex全域排序:所有 tabindex="1" 先走完才輪到所有 tabindex="2",全部走完才回頭走 tabindex="0" 與自然可聚焦的元素。所以頁面任何一個角落新增一個 tabindex="1"(第三方 widget、一年前留下的 hack),你這個容器的順序就爛了——「局部決策造成全域後果」的教科書案例,也是 F44 存在的理由。reading-flow 的 focus scope owner 就是為了把效力限制在容器裡。

實務結論:tabindex 只留兩個值。0 把非互動元素放進 Tab 序(很少用,通常是 SPA 換頁要聚焦的 h1);-1 讓元素能被 .focus() 但不進 Tab 序。其他值都要在 review 裡被質問。

top layer 與 position: absolute:z 軸上的同一個問題

對話框把視覺順序推到最上層,但循序焦點導覽仍按 DOM 位置走。這就是 F85 的成因:一個寫在文件最底部、用 position: fixed 顯示在觸發按鈕旁的選單,視覺上緊鄰按鈕,Tab 序上在四十個元素之後。

原生 <dialog>showModal() 用一個粗暴但有效的方式繞開它:讓對話框外的所有內容變成 inert,於是順序問題消失——沒有別的東西可以聚焦,順序就無從錯亂。但非 modal 的東西沒有這個豁免:popover 建立的 auto popover、自製下拉選單、tooltip,只要可聚焦又不在 DOM 上緊鄰觸發元素,就會踩 F85。W3C 給的充分技術是 SCR26:把動態內容插入到它的觸發元素之後——解法還是回到 DOM 順序。順帶一提,dialogpopover 是 Interop 2026 的 focus area 之一,但「順序按 DOM 走」不會改,那是 HTML 規格的定義。

怎麼自動偵測:Tab 序 vs getBoundingClientRect

昨天那三個測試抓得到「順序回歸」與「鍵盤陷阱」,但抓不到「順序從第一天就跟視覺不合」。要抓後者,得同時取得兩個序列再比對逆序對(inversion)。第一步:用真的 Tab 走一遍,每一站同時記下識別字串與視覺矩形。

// tests/helpers/reading-order.ts
import type { Page } from '@playwright/test';
 
export type Stop = { key: string; x: number; y: number; w: number; h: number };
 
export async function tabSequence(page: Page, max = 60): Promise<Stop[]> {
  // 從文件開頭起跳:點一個絕對不可聚焦的位置,讓 activeElement 回到 body
  await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
 
  const stops: Stop[] = [];
  const seen = new Set<string>();
 
  for (let i = 0; i < max; i++) {
    await page.keyboard.press('Tab');
    const stop = await page.evaluate(() => {
      let el: Element | null = document.activeElement;
      while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement; // 穿透 shadow DOM
      if (!el || el === document.body) return null;
      const r = el.getBoundingClientRect();
      const label = el.getAttribute('aria-label')
        ?? (el as HTMLElement).innerText?.trim().slice(0, 24)
        ?? '';
      return {
        key: `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}[${label}]`,
        // 加上捲動位移,換成文件座標,否則捲動中的元素會被量錯
        x: Math.round(r.left + window.scrollX),
        y: Math.round(r.top + window.scrollY),
        w: Math.round(r.width),
        h: Math.round(r.height),
      };
    });
    if (!stop) break;              // 焦點跑出頁面(進了瀏覽器 UI)
    if (seen.has(stop.key)) break; // 繞回起點,一圈結束
    seen.add(stop.key);
    stops.push(stop);
  }
  return stops;
}

第二步:把同一批元素按視覺順序排一次。這裡有個容易寫錯的地方——不要把「同一列的容差」直接寫進 comparator:

/** 先按垂直中心切出「列帶」,再各列內左到右。 */
export function visualOrder(stops: Stop[], band = 12): Stop[] {
  // 為什麼不寫成 sort((a,b) => Math.abs(dy) <= band ? a.x - b.x : a.y - b.y)?
  // 因為那不是嚴格弱序:A~B、B~C 但 A≁C 的情況下,
  // 排序結果會隨輸入順序漂移,同一頁跑兩次可能得到兩種答案。
  const byY = [...stops].sort((a, b) => (a.y + a.h / 2) - (b.y + b.h / 2));
  const rows: Stop[][] = [];
  for (const s of byY) {
    const row = rows.at(-1);
    const rowCy = row ? row[0].y + row[0].h / 2 : Infinity;
    if (row && Math.abs((s.y + s.h / 2) - rowCy) <= band) row.push(s);
    else rows.push([s]);
  }
  return rows.flatMap(r => r.sort((a, b) => a.x - b.x));
}
 
/** 回傳「Tab 先到 A,但視覺上 A 在 B 之後」的所有配對。 */
export function inversions(stops: Stop[], band = 12) {
  const rank = new Map(visualOrder(stops, band).map((s, i) => [s.key, i]));
  const out: Array<{ tabFirst: string; butVisuallyAfter: string; gap: number }> = [];
  for (let i = 0; i < stops.length; i++) {
    for (let j = i + 1; j < stops.length; j++) {
      const ri = rank.get(stops[i].key)!, rj = rank.get(stops[j].key)!;
      if (ri > rj) out.push({
        tabFirst: stops[i].key,
        butVisuallyAfter: stops[j].key,
        gap: ri - rj,           // 跨越幾個視覺位置,用來排嚴重度
      });
    }
  }
  return out.sort((a, b) => b.gap - a.gap);
}

第三步最重要:這是啟發式偵測器,不是合規判定器。 2.4.3 不要求焦點序等於視覺序,所以「有逆序對」不等於「違規」。已知會產生合法逆序的至少有:獨立雙欄或多欄版面、sticky header 與 skip link、position: fixed 浮動元素、視覺重疊的卡片,以及 RTL 內容(x 要反過來比)。

所以正確的用法是兩段式:CI 只斷言「逆序對的數量與內容跟基準檔一致」,任何新增的都輸出清單;然後由人看那份清單,決定是 bug 還是加進豁免。

import { test, expect } from '@playwright/test';
import { tabSequence, inversions } from './helpers/reading-order';
 
test('焦點順序與視覺順序的逆序對不增加', async ({ page }) => {
  await page.goto('/checkout');
  const stops = await tabSequence(page);
  expect(stops.length, '一個 Tab 都走不到,選擇器或起始焦點有問題').toBeGreaterThan(3);
 
  const found = inversions(stops)
    .map(i => `${i.tabFirst} → ${i.butVisuallyAfter} (gap ${i.gap})`);
 
  // 基準檔用 --update-snapshots 產生,之後每一次新增都會被 diff 出來
  expect(found.join('\n')).toMatchSnapshot('checkout-inversions.txt');
});

這就是昨天那個洞被填掉的程度:它沒有消掉人工走查,它把人工走查的輸入從「整個頁面」縮小成「一份會自己變長的清單」。 五十個 Tab 停點的結帳頁,人眼走完要十分鐘;讀三行新增逆序對要三十秒。剩下真正不可自動化的是最後那個判斷——這個逆序有沒有妨礙意義或操作。那是 2.4.3 條文裡「preserves meaning and operability」那幾個字,任何工具都算不出來。

這套方法什麼時候會失效

明確列出免得日後誤信:只測一個寬度會漏掉響應式順序反轉;display: none 的元素不在 Tab 序也不在測量範圍,摺疊內容要先展開;Canvas 與 WebGL 內部沒有 DOM,這套完全無效;getBoundingClientRect 回傳的是 transform 之後的矩形,translateY(-100%) 藏在畫面外的會量出負座標。最後是昨天那個陷阱的近親——page.keyboard.press('Tab') 是真鍵盤事件所以沒問題,但若為了加速改用 element.focus(),測試會永遠通過。

🧠 記

  • 1.3.2 Meaningful Sequence 與 2.4.3 Focus Order 都是 Level A:前者管全部內容的閱讀序,後者管互動元素的焦點序。共用充分技術 C27(讓 DOM 順序符合視覺順序)。
  • 2.4.3 不要求焦點序等於視覺序,只要求保留意義與可操作性;獨立雙欄先走右欄不算失敗。真正的 failure 是 F44(正整數 tabindex)與 F85(對話框/選單不緊鄰觸發元素)。
  • order*-reverse、grid 明確定位、position: absolute 都只動視覺順序,Tab 序與報讀器序不動;只有改 DOM 順序能讓三者一起動。
  • reading-flow / reading-order 在 CSS Display Level 4,Chrome 137 出貨,只改焦點與無障礙曝光順序、不改視覺;容器成為 focus scope owner,直接子元素的正整數 tabindex 在排序上被忽略。
  • 到 2026 年 9 月它仍是 Chromium 限定:Mozilla standards-positions #1056 自 2024-07-31 起仍是 Needs proposed position,Interop 2026 的 focus area 沒有它。只能當漸進增強,不能靠它合規。
  • 正整數 tabindex 是全域排序且不被 accessibility tree 認識(所以還要補 aria-owns),任何角落新增一個就毀掉別處的順序;只留 0-1
  • 逆序對偵測是啟發式:CI 斷言「不新增」,人只看新增那幾行。

✍️ 實踐

挑手上最複雜的一頁(結帳、儀表板、卡片牆都適合),花 20–30 分鐘把逆序對偵測接起來。

前 10 分鐘。 把三個函式抄進 tests/helpers/reading-order.ts,寫上面那個測試,跑一次產生基準檔,然後打開那個 .txt 用眼睛讀完。這一步的價值高於測試本身:你會看到自己從來沒發現的東西。

中間 10 分鐘。 逐行分成三類:BUG(真的妨礙操作,例如送出按鈕視覺上在表單最下面但 Tab 序在中間)、OK-獨立區塊(雙欄、側欄、footer)、OK-座標假警報(sticky header、position: fixed、被 transform 搬走的)。後兩類留在基準檔當豁免,第一類開 issue。

最後 10 分鐘。 對主要斷點各跑一次(用 test.use({ viewport: ... }) 開 375/768/1280 三個 project),因為順序反轉最常發生在斷點之間。三份基準檔不同是正常的。

自我檢查(全部要能勾)

  • 基準檔裡每一行逆序對我都能說出它屬於哪一類,沒有「不知道這是什麼」的行。
  • tabSequence 走到的停點數量合理——太少通常是起始焦點沒回到文件開頭,太多是有裝飾用的 tabindex="0"
  • grep -rn 'tabindex="[1-9]' 搜過,結果是零;不是零的話我知道每一處為什麼在那裡。
  • 我沒有為了消掉逆序對而加正整數 tabindex,也沒有加了 reading-flow 就當它修好(Firefox 與 Safari 使用者拿不到)。
  • 三個斷點各有一份基準檔,而且我確認過至少一個斷點的清單跟另一個不同——如果三份完全一樣,很可能是 viewport 沒真的切換。

🔗 延伸學習

💬 問 AI

我要處理一個「DOM 順序與視覺順序不一致」的頁面,目標是同時滿足
WCAG 2.2 SC 1.3.2 Meaningful Sequence (A) 與 SC 2.4.3 Focus Order (A)。
 
【我的情況】
- 框架與版本:[React 19 / Vue 3 / Svelte 5 / 純 HTML ...]
- 這一頁的用途:[結帳表單 / 儀表板 / 商品列表 ...]
- 版面用了什麼:[flex 且有 order / flex-direction: row-reverse /
  grid 明確 grid-row+grid-column / position: absolute / 以上皆有]
- 斷點與各斷點的視覺順序:[例如 375 單欄 A→B→C;1280 雙欄,B 在右欄最上]
- 目前 DOM 順序:[貼上簡化後的結構]
- 正整數 tabindex:[有,共 N 處 / 沒有]
- 非 modal 彈層(popover / 自製下拉 / tooltip):[有,寫在 DOM 哪裡 / 沒有]
- 支援瀏覽器:[需支援 Firefox 與 Safari / 只需 Chromium]
 
【請依序做】
1. 判斷我這頁的順序不一致屬於 1.3.2、2.4.3、還是兩者。直接引 W3C 的
   failure 編號(F1 / F44 / F85)說明我踩到哪個;都沒踩到就明講
   「這是 best practice 層級,不是合規失敗」。
2. 給我一個「所有斷點都意義正確」的 DOM 順序,說明取捨依據。若做不到
   全部斷點都對齊視覺,明確說出犧牲哪個斷點、為什麼那個犧牲不影響
   意義或可操作性。
3. 用 grid 明確定位(不要用 order)改寫版面,讓步驟 2 的 DOM 順序不變。
4. 如果 reading-flow 有幫助,用 @supports 包起來當漸進增強,並標註它在
   Firefox 與 Safari 不生效、所以步驟 2 的 DOM 順序必須自己就合規。
5. 針對非 modal 彈層,依 SCR26 給出「插入到觸發元素之後」的實作。
6. 寫一個 Playwright 測試:真鍵盤事件取 Tab 序、getBoundingClientRect 取
   視覺序,輸出逆序對清單並對基準檔快照比對。注意先按 y 切列帶再排 x,
   不要把容差寫進 comparator(那不是嚴格弱序)。
 
【限制】
- 不准建議用正整數 tabindex 解決順序問題。
- 不准宣稱 reading-flow 已經是 Baseline 或跨瀏覽器可用;
  如果你要講支援度,請講「Chrome 137 起、Chromium 限定」。
- 不准宣稱 2.4.3 要求焦點順序必須等於視覺順序——它不要求。
- 不准把逆序對偵測講成合規判定器;請明確列出它會產生假警報的情況。
- 最後請告訴我:這些改動之後,還有哪些部分只能靠人工走查或真實輔具測試。