昨天談的 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-rl 和 sideways-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-left | margin-inline-start | left |
margin-right | margin-inline-end | right |
margin-top / margin-bottom | margin-block-start / -end | top / bottom |
padding: 0 1rem | padding-inline: 1rem | 左右 |
padding: 1rem 0 | padding-block: 1rem | 上下 |
border-left | border-inline-start | left |
left / right(定位) | inset-inline-start / -end | left / right |
top / bottom | inset-block-start / -end | top / bottom |
border-top-left-radius | border-start-start-radius | 左上 |
border-bottom-right-radius | border-end-end-radius | 右下 |
width / height | inline-size / block-size | 寬 / 高 |
min-width / max-height | min-inline-size / max-block-size | — |
text-align: left | text-align: start | 靠左 |
float: left | float: inline-start | 靠左浮動 |
overflow-x / overflow-y | overflow-inline / overflow-block | 橫向 / 縱向 |
圓角那組的命名最容易記錯:border-start-start-radius 的兩個 start 分別是 block 軸與 inline 軸,順序是 block 在前。所以它是「block-start 側 × inline-start 側」的那個角,橫排 ltr 就是左上。
支援度上這些早就不是新東西。依 MDN 的 Baseline 標示,margin-inline-start、border-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-start 與 clear: 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),但到現在還沒有可用的實作。
transform:translateX()、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 right、45deg 都是物理角度。漸層做的分隔線、fade-out 遮罩、skeleton 的掃光動畫,在 RTL 下方向都會反。
clip-path、mask-position、object-position、perspective-origin 同理。還有 text-shadow、scroll-padding 雖然有 scroll-padding-inline 但 scroll-snap 的某些互動細節仍受物理捲動方向影響。
實務上我會在專案裡放一條 stylelint 規則,把 left/right/margin-left 這類物理屬性列為 error,但對 background-position、transform、box-shadow、linear-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、可拖動的分隔線,這些是靠 pointermove 的 deltaX 判斷方向,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" 本身的定義是:在 textarea 與 output 上等於 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-position 與 ruby-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-rl 與 vertical-lr 的選擇:內容是傳統中日文書籍就用 vertical-rl(第一行在右)。vertical-lr 在中日文內容裡幾乎只出現在一種情況——你想要垂直文字但希望捲動方向跟橫排一致(往下捲時內容往左推的直覺對某些 web app 比較順)。混用會讓使用者的捲動預期崩潰,一個文件裡選一個。
邏輯屬性怎麼影響焦點順序
接昨天的三條序:writing-mode 與 direction 會改變視覺序,但不會改變 DOM 序,也不會改變預設的焦點序。這帶來一個昨天沒提的推論——昨天用 getBoundingClientRect() 的 top、left 算逆序對來偵測焦點序與視覺序不一致的那套自動化,在 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 的是 order 跟 row-reverse 這種在方向系統之上再翻一次的操作。RTL 下用 row-reverse 幾乎一定是 bug,因為作者通常是想要「靠右」,而在 RTL 那已經是預設。
還有一個常被忽略的無障礙細節:lang 與 dir 要一起設。報讀器選語音是看 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-position、transform、box-shadow、linear-gradient、clip-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: mixed 加 text-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 模式下跑起來會不會全部誤報?
🔗 延伸學習
- CSS logical properties and values — MDN:完整的物理/邏輯對照與各屬性的 Baseline 狀態。
- CSS Logical Properties and Values Module Level 1 — CSSWG Editor’s Draft:規格本體,看它怎麼定義 mapping 與簡寫的層疊行為。
- UAX #9: Unicode Bidirectional Algorithm:雙向演算法規範,重點看 isolates 與 rule N0(括號配對)那幾節。
- Styling vertical Chinese, Japanese, Korean and Mongolian text — W3C i18n:直排排版最實用的一份文件,
text-orientation與縱中橫都有圖例。
💬 問 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 之類的建置期翻轉工具,我想理解原理。
如果某個瀏覽器支援度你不確定,直接說不確定,不要猜。