昨天談的 DOM 序、視覺序、焦點序這三條線,以及 reading-flow 那個到 2026 年還被關在 Chromium 裡的解法,全部建立在一個沒說出口的前提上:文字由左往右流、段落由上往下疊。這個前提在中文、英文、日文橫排裡成立,所以「視覺上比較前面」等於「比較靠左上」,逆序對算得出來、getBoundingClientRect().left 排序有意義。把同一份 DOM 丟進阿拉伯文介面,left 立刻變成「行尾」;丟進直排的中文詩集,top 變成「行首」而 right 變成「文件開頭」。physical properties 不是壞了,是它們描述的東西跟「讀者從哪裡開始讀」脫鉤了。今天要處理的就是這層脫鉤:CSS 用什麼詞彙重新描述方向、哪些地方它至今仍拒絕重新描述、以及為什麼把 left 換成 inline-start 之後你的 RTL 版本還是會壞。

📖 學

writing-mode 決定軸向,direction 決定軸上的方向

CSS 把「方向」拆成兩個正交的概念,這是理解後面所有東西的鑰匙。writing-mode 決定行的排列軸(block axis)跟文字流動軸(inline axis)分別落在哪個物理方向上;direction 決定 inline axis 上是往哪一邊跑。

writing-mode: horizontal-tb 是預設值:inline axis 水平、block axis 由上往下。vertical-rl 讓 inline axis 變垂直(文字由上往下),block axis 由右往左——傳統中日文書籍就是這個模式,第一行在最右邊。vertical-lr 一樣是垂直行,但新的行往右邊長,這是蒙古文的排法,也是某些日文 UI 刻意選的變體,因為捲軸行為跟橫排的左到右比較一致。

sideways-rlsideways-lr 是另一組:它們不是「把字轉正」,而是把整行文字(包含中日文字元)整個側躺 90 度。這在做垂直座標軸標籤、側邊書背式導覽時很有用,因為它不會把英文字母一個一個立起來。這兩個值的支援在 2026 年才算補齊——依 MDN 與 caniuse 的相容性表,Firefox 很早就有(43),Safari 18.4 補上,Chrome 與 Edge 到 132 才實作。如果你的專案還要吃比較舊的 Chromium 版本(Electron、企業內嵌瀏覽器),這裡要留 fallback。

direction 則只有 ltr / rtl 兩個值,它作用在 inline axis 上。關鍵在於:在 HTML 裡你幾乎永遠不該用 CSS direction,要用 HTML 的 dir 屬性。理由是 dir 屬性同時會影響 UBA(Unicode 雙向演算法)的計算基準、表單元素的行為、以及在 CSS 被關掉時仍然生效;CSS direction 只改渲染。W3C 國際化工作組長年的建議是「結構用 markup、樣式用 CSS」,方向性屬於結構。

unicode-bidi 是第三個角色,它管的不是版面而是「這段文字要不要另起一個雙向演算法的作用域」。isolate 把內容切成獨立的一段、對外只留一個中性字元的佔位;embed 舊行為會讓內部方向溢出去影響鄰居;plaintext 讓每個段落用第一個強方向字元自己決定基準方向;bidi-override 強制忽略字元本身的方向性。九成情況你需要的是 isolate,而且通常不用自己寫——後面會講。

邏輯屬性對照表

CSS 用 inline / block 取代 x / y,用 start / end 取代 left / right / top / bottom。完整對照大致如下(以 horizontal-tb + ltr 為基準):

物理屬性邏輯屬性ltr 橫排時等於
margin-leftmargin-inline-startleft
margin-rightmargin-inline-endright
margin-top / margin-bottommargin-block-start / -endtop / bottom
padding: 0 1rempadding-inline: 1rem左右
padding: 1rem 0padding-block: 1rem上下
border-leftborder-inline-startleft
left / right(定位)inset-inline-start / -endleft / right
top / bottominset-block-start / -endtop / bottom
border-top-left-radiusborder-start-start-radius左上
border-bottom-right-radiusborder-end-end-radius右下
width / heightinline-size / block-size寬 / 高
min-width / max-heightmin-inline-size / max-block-size
text-align: lefttext-align: start靠左
float: leftfloat: inline-start靠左浮動
overflow-x / overflow-yoverflow-inline / overflow-block橫向 / 縱向

圓角那組的命名最容易記錯:border-start-start-radius 的兩個 start 分別是 block 軸與 inline 軸,順序是 block 在前。所以它是「block-start 側 × inline-start 側」的那個角,橫排 ltr 就是左上。

支援度上這些早就不是新東西。依 MDN 的 Baseline 標示,margin-inline-startborder-inline-start 這批自 2020 年 1 月起就是 Baseline Widely available;inset-inline-start 是 2021 年 4 月;border-start-start-radius 是 2021 年 9 月(Chrome/Edge 89、Firefox 66、Safari 15)。比較晚的是 float: inline-startclear: inline-end,Chrome 與 Edge 到 118(2023 年 10 月)才跟上,Safari 15、Firefox 55 反而更早。

有意思的是規格本身的狀態:CSS Logical Properties and Values Module Level 1 到 2026 年仍是 Editor’s Draft(草案上標的日期是 2025-12-12),/TR/ 底下最後一份正式發布的工作草案還停在 2018-08-27。實作跑在規格前面已經很多年了,這也是為什麼有些邊角行為(inline-size<table> 上、簡寫屬性與物理簡寫的層疊順序)在不同引擎間還有微小差異。

到 2026 年仍然沒有邏輯版本的那一類

這是最容易漏掉、也最會在 RTL 驗收時炸開的部分。有一整群 CSS 屬性至今只認物理座標,你用了邏輯屬性寫版面,這些地方還是留著硬編碼的左右:

background-position 只有 background-position-x / -y,沒有 inline/block 版本。你寫 background-position: right 12px center 放一個下拉箭頭,RTL 下箭頭還是黏在右邊。CSSWG 有在 css-backgrounds-4 討論讓 <position> 接受邏輯關鍵字(issue #549、#12132),但到現在還沒有可用的實作。

transformtranslateX()scaleX()rotate() 全部是物理座標。一個「點開時往右滑出」的抽屜、一個 translateX(-100%) 的側欄動畫,換成 RTL 就是往錯的方向跑。fxtf-drafts issue #311 在談 logical transforms,csswg-drafts issue #7051 在談 transform-origin 的邏輯值,兩邊都還在討論階段。目前的實務解法是用 CSS 自訂屬性把方向係數抽出來:

:root { --dir: 1; }
[dir="rtl"] { --dir: -1; }
 
.drawer {
  transform: translateX(calc(var(--dir) * -100%));
}

box-shadow 的 offset 也是物理的 x y,同一套陰影在 RTL 下光源方向會反過來。多數設計系統的陰影是「往下」為主、左右偏移很小,所以視覺上勉強撐得住,但只要你的陰影有明顯的橫向位移(例如卡片抽起來的斜投影),就得跟著 --dir 翻。

linear-gradient() 的方向to right45deg 都是物理角度。漸層做的分隔線、fade-out 遮罩、skeleton 的掃光動畫,在 RTL 下方向都會反。

clip-pathmask-positionobject-positionperspective-origin 同理。還有 text-shadowscroll-padding 雖然有 scroll-padding-inlinescroll-snap 的某些互動細節仍受物理捲動方向影響。

實務上我會在專案裡放一條 stylelint 規則,把 left/right/margin-left 這類物理屬性列為 error,但對 background-positiontransformbox-shadowlinear-gradient 這幾個「無法邏輯化」的地方,強制要求旁邊寫 /* rtl-checked */ 註解——目的不是禁止,是逼作者當下就想一次 RTL 會怎樣。

更正一:「用了邏輯屬性就自動支援 RTL」

這句話在推特跟部落格上流傳很廣,它錯的程度大概是「用了 semantic HTML 就自動無障礙」那個等級——方向對,但把一成的工作講成十成。邏輯屬性解決的只是盒模型與文字對齊。以下這些一律不會自動翻,全部需要人工判斷:

方向性圖示。返回箭頭、「下一步」的 chevron、undo/redo、縮排/凸排、播放鍵、清單的展開三角,這些在 RTL 都要鏡射。但同一批圖示裡有一堆不能鏡射:時鐘、放大鏡(習慣上不翻)、勾號、垃圾桶、含有數字或拉丁字母的圖示、真實世界物件(滑鼠、書本封面)。播放鍵尤其微妙——媒體播放鍵在多數 RTL 系統上不翻,因為它已經被理解成「播放」這個抽象符號而非「往前」。所以正確做法不是 [dir="rtl"] svg { transform: scaleX(-1); } 全域翻,而是給需要翻的那批加 class:

.icon--directional:dir(rtl) { transform: scaleX(-1); }

:dir() 偽類是查詢元素的實際方向性,比 [dir="rtl"] 屬性選擇器可靠,因為方向會繼承而屬性不一定寫在每個元素上。

進度條與滑桿。原生 <progress><input type="range">dir="rtl" 下多數引擎會自己翻,但你一旦用 div + width: 60% 自幹,填色就會從錯的一端長出來。改成 inline-size 加上父層 text-align: start 不夠,還要確認 transform-origin

拖曳與手勢。滑動刪除、carousel 的 swipe、可拖動的分隔線,這些是靠 pointermovedeltaX 判斷方向,deltaX 永遠是物理的。RTL 下「往左滑」的語意是「往前」而不是「往後」,要在手勢層乘上方向係數。

動畫的進出場方向。Toast 從哪一側滑入、頁面轉場往哪邊推、抽屜從哪邊拉出,全部是 transform,全部要處理。

捲動位置。RTL 水平捲動容器的 scrollLeft 起始值在歷史上是各家不同(有負值、有 0、有最大值三種流派),現在規範統一往「起點為 0、往 start 方向為負」收斂,但如果你在讀寫 scrollLeft 做 carousel 指示器,一定要改用 scrollend 事件加 IntersectionObserver,不要自己算像素。

更正二:在 RTL 段落裡塞 LRM/RLM 控制字元

處理「阿拉伯文句子中夾一個電話號碼、一段程式碼、一個 URL、一個使用者暱稱」時,常見的舊做法是在字串前後塞 U+200E(LRM)或 U+200F(RLM)這類不可見的方向標記字元。這在 2026 年是錯的做法,理由有三層。

第一,UBA 從 Unicode 6.3(2013)就引入了方向隔離(isolates):LRI / RLI / FSI / PDI 這組字元,以及對應的 unicode-bidi: isolate。隔離跟嵌入(embedding)的差別在於,隔離會讓內部文字對外表現成一個中性字元,不會把方向性洩漏出去影響鄰接的文字;舊的 LRM/RLM 只是「插一個強方向字元進去」,是在用副作用調版面,鄰居的方向仍會被拉扯。同一版還加了括號配對規則 N0,解決 (هذا [test]) 這種括號跑到錯邊的老問題。UAX #9 目前的版本是對應 Unicode 17.0.0 的 Revision 51(2025-08-13 發布)。

第二,控制字元會進到你的資料裡。使用者暱稱、搜尋索引、複製貼上、資料庫比對、URL 參數,全部會多出看不見的字元,日後很難清。

第三,正確工具已經是標準的一部分且相容性極佳:

<!-- 使用者產生內容,方向未知 -->
<p>عدد التعليقات على <bdi>User_名前123</bdi> هو 42.</p>
 
<!-- 已知是 LTR 的技術內容 -->
<p dir="rtl">الرابط هو <span dir="ltr">https://example.com/a/b</span></p>
 
<!-- 讓瀏覽器依內容第一個強方向字元自行判斷 -->
<td dir="auto">{{ user.displayName }}</td>

<bdi> 的預設樣式表就是 unicode-bidi: isolate,而且它的 dir 屬性預設為 auto不從父層繼承——這是它跟 <span dir="auto"> 的實質差別。dir="auto" 本身的定義是:在 textareaoutput 上等於 unicode-bidi: plaintext,在 <bdo> 上等於 bidi-override isolate,其餘情況等於 isolate

規則好記:只要是你不能保證方向的內容(使用者輸入、API 回傳、翻譯字串插值),就包 <bdi>;只要是你確定方向的技術字串(URL、程式碼、產品型號),就標明確的 dir 數字本身在 UBA 裡是弱方向字元,單獨的 42 不會出事,但「42 GB」「+886-2-1234」「v2.1.0」這種數字混標點或拉丁字母的組合,就是典型的踩雷點。

直排中文的實務

writing-mode: vertical-rl 只是起點,中文直排真正要調的是字元的立法與標點。

text-orientation 決定 inline axis 上的字元怎麼擺。mixed(預設)讓中日韓字元保持直立、拉丁字母側躺;upright 強制所有字元直立,包含英文字母——用在少量的專有名詞可以,整段英文會非常難讀;sideways 全部側躺。

text-combine-upright: all 是直排排版的關鍵一招,它把一小段字(通常兩到四個字元)壓成一個字寬的方塊,這就是日文所謂的「縦中横」,用來處理年份「2026」、頁碼、「20%」這類需要橫著讀的短數字。這個值自 2022 年 3 月起跨瀏覽器可用。但要注意:規格裡定義的 digits 值(自動把連續 N 個以下的 ASCII 數字合併)到 2026 年仍然沒有任何瀏覽器實作,所以你不能靠它自動化,只能在內容層包 span:

.tate { writing-mode: vertical-rl; text-orientation: mixed; }
.tcy  { text-combine-upright: all; }
<p class="tate">民國 <span class="tcy">115</span> 年出版。</p>

ruby 用來標注音(台灣的注音、日文的振り仮名)。ruby-positionruby-align 在 MDN 的 Baseline 標示是「2024 Newly available」(2024 年 12 月),也就是說它剛跨過門檻不久,舊裝置要留心。直排時注音預設落在字的右側(ruby-position: over 在垂直書寫模式下對應到 inline-start 側),台灣的教科書慣例也是如此。

標點是最麻煩的一塊。中文全形標點在直排時位置要調整(句號、逗號在方格的右上,直排時視覺上要落在字的上方偏右),CSS 有 text-spacing-trim 之類的機制在處理標點擠壓,但直排的標點位置很大程度依賴字型本身是否提供 vert / vrt2 OpenType feature。實務結論是:直排中文一定要指定有直排字符集的字型(例如源流明體、思源宋體的直排變體),否則瀏覽器只能靠旋轉硬湊,括號跟破折號會歪。台灣與日本出版界對直排的常見要求還包括每頁固定行數字數、避頭尾(行首不能出現句號、行尾不能出現開括號)——避頭尾在 CSS 裡對應 line-break: strict,但精細度離排版軟體還有距離,這一項我標「實務上仍需人工校對」。

vertical-rlvertical-lr 的選擇:內容是傳統中日文書籍就用 vertical-rl(第一行在右)。vertical-lr 在中日文內容裡幾乎只出現在一種情況——你想要垂直文字但希望捲動方向跟橫排一致(往下捲時內容往左推的直覺對某些 web app 比較順)。混用會讓使用者的捲動預期崩潰,一個文件裡選一個。

邏輯屬性怎麼影響焦點順序

接昨天的三條序:writing-modedirection 會改變視覺序,但不會改變 DOM 序,也不會改變預設的焦點序。這帶來一個昨天沒提的推論——昨天用 getBoundingClientRect()topleft 算逆序對來偵測焦點序與視覺序不一致的那套自動化,在 RTL 或直排下會全部誤報,因為排序函式硬編碼了「左上優先」。要修的話,比較法必須改成依 writing mode 決定:

// 取代硬編碼的 (top, left) 排序
const dir = getComputedStyle(document.documentElement);
const rtl = dir.direction === 'rtl';
const vertical = dir.writingMode.startsWith('vertical');
 
function visualKey(rect) {
  if (vertical) {
    // 直排:block 軸是水平的,inline 軸是垂直的
    const block = dir.writingMode === 'vertical-rl' ? -rect.right : rect.left;
    return [block, rect.top];
  }
  return [rect.top, rtl ? -rect.right : rect.left];
}

另一件事:reading-flow 在昨天的結論是 Chromium 限定,它處理的是 flex/grid 重排後的焦點序。它跟邏輯屬性是互補而非重疊的——flex-direction: row 在 RTL 下主軸自動變成由右到左(因為 flexbox 的主軸本來就定義在 inline axis 上),這部分不需要 reading-flow;需要 reading-flow 的是 orderrow-reverse 這種在方向系統之上再翻一次的操作。RTL 下用 row-reverse 幾乎一定是 bug,因為作者通常是想要「靠右」,而在 RTL 那已經是預設。

還有一個常被忽略的無障礙細節:langdir 要一起設。報讀器選語音是看 lang,決定唸的順序與雙向處理是看 dir。只設 dir="rtl"lang 還留在 en,NVDA 或 VoiceOver 會用英文語音去唸阿拉伯文,結果是字母拼讀或直接跳過。

測試手法

全站 dir="rtl" 翻轉快照。最便宜的第一道防線:在 Playwright 裡跑兩次視覺回歸,一次正常、一次在 page.addInitScript 裡把 document.documentElement.dir = 'rtl',然後人工看差異圖。你要找的不是「畫面翻過來了」(那是預期),而是沒有跟著翻的東西——那些留在原地的箭頭、陰影、漸層、背景圖示,在差異圖上會非常明顯。

偽本地化(pseudo-localization)。把所有可翻譯字串經過一層轉換:加重音符號(Hello → Ĥéļļö)、前後加標記字元([!! Hello !!])、並把長度撐長 30–40%(模擬德文與芬蘭文的膨脹率)。這一招同時抓三種 bug:字串被硬編碼在程式裡(沒被轉換的就是漏網的)、容器寬度不夠導致截斷、以及用 width 而非 min-inline-size 造成的溢位。想同時測 RTL 就再做一個把字串包在 RLI/PDI 裡的變體。

真實內容抽驗。偽本地化測不到的是字型與行高。阿拉伯文的字高(尤其含變音符號時)比拉丁文高,行高設死的 line-height: 1.4 在阿拉伯文會裁到字;泰文更誇張。所以最後一定要拿一段真的阿拉伯文與一段真的直排中文貼進去看。

🧠 記

writing-mode 決定 inline/block 兩軸落在哪個物理方向,direction 決定 inline 軸的流向,unicode-bidi 決定雙向演算法的作用域切分,三者職責不重疊。HTML 內容用 dir 屬性而非 CSS direction,因為前者同時是結構資訊與 UBA 的計算基準。

邏輯屬性的命名是 {property}-{inline|block}-{start|end},圓角例外地寫成 border-{block}-{inline}-radius(block 在前)。這批屬性自 2020–2021 年起就是 Baseline Widely available,但規格本身到 2026 年仍是 Editor’s Draft。

「用了邏輯屬性就自動支援 RTL」是本篇最需要更正的一句:邏輯屬性只覆蓋盒模型與文字對齊,background-positiontransformbox-shadowlinear-gradientclip-path 到 2026 年都還沒有邏輯版本,圖示鏡射、動畫方向、拖曳手勢、捲動計算則根本不在 CSS 的守備範圍。

RTL 段落裡混 LTR 內容,用 <bdi> / dir="auto" / unicode-bidi: isolate,不要塞 LRM/RLM 控制字元——隔離(isolates)自 Unicode 6.3 起就是取代嵌入的正解,且控制字元會汙染你的資料。UAX #9 現行版本是 Unicode 17.0.0 Revision 51。

直排中文靠 text-orientation: mixedtext-combine-upright: all 處理縱中橫;digits 值至今無人實作,必須手動包 span。直排一定要配有直排字符集的字型,否則標點位置會歪。

昨天那套用 getBoundingClientRect()top/left 算逆序對的視覺序檢測,在 RTL 與直排下會全部誤報,排序鍵必須依 writing mode 動態決定。

✍️ 實踐

拿你手上任一個現有頁面(首頁或任一表單頁都行),做一次 RTL 壓力測試。20 分鐘。

步驟一(3 分鐘):開瀏覽器 DevTools,在 Console 執行 document.documentElement.dir = 'rtl'。截圖存下來。

步驟二(7 分鐘):對照原圖找出「沒有跟著翻」的元素。重點掃這六類——箭頭與 chevron、卡片陰影的偏移方向、任何用背景圖做的小圖示、進度條或滑桿的填充起點、Toast 或側欄的滑入方向、以及任何 text-align: left 寫死的地方。把找到的每一個記在紙上或 issue 裡。

步驟三(5 分鐘):在 Console 執行下面這段,把所有仍使用物理屬性的規則印出來,跟步驟二的清單交叉比對:

[...document.styleSheets].flatMap(s => { try { return [...s.cssRules]; } catch { return []; } })
  .filter(r => r.style)
  .map(r => ({ sel: r.selectorText, hits: ['margin-left','margin-right','padding-left','padding-right','left','right','text-align','float','background-position','transform','box-shadow'].filter(p => r.style.getPropertyValue(p)) }))
  .filter(o => o.hits.length)
  .slice(0, 50);

步驟四(5 分鐘):挑一個最明顯的問題修掉,並用 --dir 係數的寫法處理其中一個 transform

自我檢查(每一項要能明確答是或否):

  • 我能指出至少三處「邏輯屬性救不了、必須人工處理」的地方嗎?
  • 我修的那個 transform 在 LTR 下行為完全沒變嗎?
  • 頁面上有使用者產生的內容(暱稱、留言、搜尋關鍵字)嗎?它們包了 <bdi>dir="auto" 嗎?
  • <html> 上的 lang 有跟著 dir 一起改嗎?
  • 我的視覺回歸測試在 RTL 模式下跑起來會不會全部誤報?

🔗 延伸學習

💬 問 AI

我要幫一個現有的 React + Tailwind 專案加上 RTL(阿拉伯文)支援。請幫我做三件事:
 
1. 列出一份「邏輯屬性救不了」的稽核清單,針對這幾類:background-position、
   transform / translateX、box-shadow、linear-gradient、clip-path、
   以及 JS 裡讀寫 scrollLeft 與 pointer deltaX 的地方。每一類給我一個
   grep / ripgrep 的搜尋樣式,以及該怎麼改的最小範例。
 
2. 幫我設計一套用 CSS 自訂屬性做方向係數(--dir: 1 / -1)的模式,
   要能同時涵蓋 CSS transform 與 JS 手勢判斷,並說明它在
   巢狀 dir 切換(頁面是 rtl 但某個區塊是 ltr)時會不會失效。
 
3. 圖示鏡射:給我一份判斷準則,哪些圖示在 RTL 要鏡射、哪些不要,
   並用 :dir(rtl) 偽類寫出實作。特別說明播放鍵、放大鏡、時鐘、
   含數字的圖示這幾個爭議案例的業界慣例。
 
限制:不要建議我用 rtlcss 之類的建置期翻轉工具,我想理解原理。
如果某個瀏覽器支援度你不確定,直接說不確定,不要猜。