iOS Safari実機だけで罫線がズレる:分数pxのline-heightがfloorされCSS computed値と乖離する問題
iOS Safari(実機)では、CSSで指定した分数pxのline-heightが整数pxにfloorされ、結果としてCSS computed valueと実際の描画行送りがズレるという挙動があります。background-image / mask-imageのrepeating-linear-gradientで1行ごとに罫線を引いているUIでは、行が増えるごとにズレが累積し、実用上6〜7行目から目視できるレベルになります。
これはWebKit Bug #225695 — REGRESSION (Safari 14.1): Fractional CSS line-height value translates to rounded (floored) element heightとして2021年から報告されている既知の挙動で、Safari 14.1以降のWebKitに存在し、現行(Safari 18.x / 26.x含む)でも未修正です。
発見を難しくしているのは次の点です。
- macOS Safari / Chrome / Firefox / Edgeでは再現しない(CSS値どおりに描画される)
- XcodeのiOSシミュレータでも再現しない(iOS 17.5 / 18.6 / 26.0で確認済み)
- iOS実機でだけ発症するため開発フローで気付きにくい(iOSシミュレーターのレンダリング処理は実機と微妙に異なる)
ライブデモ
下のデモは、font-size: 15px; line-height: 1.5(= 1lh が分数pxの22.5px)で各行に罫線を引いたものです。getComputedStyleで得たCSS computed値と、JSで実測した実描画値を読み出しに表示しています。
このズレはiOS Safari実機(およびiPad実機)でだけ目視できます。 macOS Safari / Chrome / Firefox / iOSシミュレータでは元から揃っており、読み出しの「差 / 行」も0.00pxのままです。実機で開いて確認してください。
再現条件
以下の条件が揃うと発症します。
- 描画対象がiOS Safari実機(シミュレータ不可、macOS Safari不可)
- CSSの
line-heightが分数pxに解決される- 例:
font-size: 11px; line-height: calc(53 / 22)→1lh= 26.5px - 例:
font-size: 14px; line-height: 1.4→1lh= 19.6px
- 例:
mask-imageまたはbackground-imageのrepeating-linear-gradientで1lh(もしくは同値の固定px)をストライドにしている- テキストの行数が概ね6行以上
再現コード(フレームワークフリー)
下記をブラウザで開き、iOS実機で表示すると7行目あたりから罫線が文字とズレ始めます。
<!DOCTYPE html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>iOS Safari line-height drift repro</title><style> body { padding: 20px; font-family: serif; } .ruled { width: 280px; /* 11px × (53/22) = 26.5px (分数px!) */ font-size: 11px; line-height: calc(53 / 22); position: relative; z-index: 0; } /* 各行の下に 0.5px の罫線を引きたい */ .ruled::before { position: absolute; inset: 0; z-index: -1; pointer-events: none; content: ""; background: black; opacity: 0.35; mask-image: repeating-linear-gradient( to bottom, transparent 0, transparent calc(1lh - 0.5px), #000 calc(1lh - 0.5px), #000 1lh ); }</style></head><body> <p class="ruled"> 1行目のテキストです。<br> 2行目のテキストです。<br> 3行目のテキストです。<br> 4行目のテキストです。<br> 5行目のテキストです。<br> 6行目のテキストです。<br> 7行目のテキストです。<br> 8行目のテキストです。<br> 9行目のテキストです。<br> 10行目のテキストです。<br> 11行目のテキストです。<br> 12行目のテキストです。 </p></body></html>
観察される挙動
| 環境 | 結果 |
|---|---|
| iOS実機Safari | 7〜8行目あたりから罫線が文字とズレ始め、行が下に行くほど乖離が大きくなる |
| iOSシミュレータ(Xcode 17.5 / 18.6 / 26.0) | ズレない |
| macOS Safari | ズレない |
| Chrome / Firefox / Edge(全OS) | ズレない |
| iPad実機Safari | iOSと同じく発症 |
原因
CSSのcomputed valueとしてはline-height: 26.5pxが保たれていますが、iOS WebKitのインラインレイアウトがレイアウト時に行ボックス高を整数pxにfloorしています。mask-imageの1lhはcomputed value(26.5px)として展開されるため、行ボックス(実描画26px)とストライド(26.5px)の間に0.5px / 行の差が生まれて累積します。
n 行目の文字ベースライン位置 = n × 26 (iOS 実機の実描画)n 行目のマスク罫線位置 = n × 26.5 (CSS の 1lh) 差 = n × 0.5px
n = 7で3.5px、n = 12で6pxの乖離となり、目視で明確にズレて見えます。
CSS Working Group / WebKitの対応状況は以下のとおりです。
- WebKit Bug #225695でNEWのまま放置
- 関連issueとして#261463 / #261212(line-height丸め)
- CSS仕様上はインラインボックス高を整数pxにスナップすることに規定がなく、ブラウザ実装依存(Chrome / Firefoxはスナップしない)
実際に検証してみる
getComputedStyle(el).lineHeightはCSS computed valueを返すため、実描画値と乖離している環境ではこの値は信用できません。同じフォント設定のprobe要素を一時的にDOMへ挿入し、複数行の合計高さから1行あたりの実描画高さを実測します。
const measureRenderedLineHeight = (el: HTMLElement, sampleLines = 20): number => { const cs = getComputedStyle(el) const probe = document.createElement('div') Object.assign(probe.style, { position: 'absolute', left: '-9999px', top: '0', visibility: 'hidden', margin: '0', padding: '0', border: '0', fontFamily: cs.fontFamily, fontSize: cs.fontSize, fontWeight: cs.fontWeight, fontStyle: cs.fontStyle, lineHeight: cs.lineHeight, letterSpacing: cs.letterSpacing, fontFeatureSettings: cs.fontFeatureSettings, }) for (let i = 0; i < sampleLines; i++) { if (i > 0) probe.appendChild(document.createElement('br')) probe.appendChild(document.createTextNode('X')) } document.body.appendChild(probe) const h = probe.getBoundingClientRect().height document.body.removeChild(probe) return h / sampleLines}
実機で以下のように比較すると差が見えます。
await document.fonts.readyconst target = document.querySelector<HTMLElement>('.ruled')!const cs = getComputedStyle(target)console.log('CSS lh:', parseFloat(cs.lineHeight)) // 26.5 (全環境)console.log('実描画:', measureRenderedLineHeight(target))// macOS / Chrome: 26.5// iOS 実機: 26 ← ここが乖離
割と気付きにくい理由
- シミュレータで再現しない: XcodeのiOSシミュレータはmacOSのWebKitを経由するため、レイアウト経路がデバイス実機と異なります。シミュレータしか使わない開発フローでは発見できません
- DPRやGPUの問題と誤認しやすい: 「実機だけ」「3x DPRだけ」の症状はGPUレイヤー / サブピクセルラスタライズと混同しやすいです。スクロール内transformを疑って
clearProps: 'transform'などを試しても直りません(これらは無関係) getComputedStyleで見てもCSS値どおり: CSS computed valueとしては仕様どおりの26.5pxが返ります。1lhも同じ値で展開されます。実描画値だけが乖離しています@supports/ メディアクエリで分岐できない: iOS Safari実機を識別するCSSフィーチャークエリは存在しません- 発症条件がコンテンツ依存: 行数が少ないと累積誤差が見えません。短文UIでレビューが完了し、長文UIで初めて発症します
対処方針
案A. JSで実描画line-heightを実測 → CSS変数に注入
CSSの見た目を一切変えず、全環境で正しく揃います。実描画値をCSS変数として注入し、mask-imageのストライドに使います。
const lh = measureRenderedLineHeight(textEl)textEl.style.setProperty('--text-line-height', `${lh}px`)
.ruled::before { mask-image: repeating-linear-gradient( to bottom, transparent 0, transparent calc(var(--text-line-height, 1lh) - 0.5px), #000 calc(var(--text-line-height, 1lh) - 0.5px), #000 var(--text-line-height, 1lh) );}
- フォールバックの
1lhは「CSS値 = 実描画値」の環境(macOS / Chrome等)で機能する - iOS実機ではJS注入値(整数px)が使われるため、floorの影響を受けない
- フォントロード後(
document.fonts.ready後)に1度実行すれば十分
案B. line-heightを整数pxに丸める
設計が許すなら最も単純な対処です。line-height: 26pxのように整数pxに固定すれば、iOSのfloorが無関係になります。
/* before: 11 × (53/22) = 26.5px */.ruled { font-size: 11px; line-height: 26px; }
行間が0.5px広がる/狭まるので、デザインの調整余地が必要です。
案C. @font-faceのmetric overrideでline-boxを定義しなおす
CSS Fonts Module Level 4のascent-override / descent-override / line-gap-override(Safari 17+サポート)でフォントのstrutをCSS line-heightより小さくすれば、iOSのline-box計算経路が変わりfloorの影響を回避できる場合があります。ただしself-hostが前提のため、和文フォントでは現実的でないことが多いです。
案D. 罫線をmask-imageではなくtext-decoration: underlineで実装する
各行の文字ベースラインに自動追従するため、原理的にズレません。ただしtext-decoration-styleの表現力がmask-imageより弱く(solid / double / dotted / dashed / wavyのみ)、デザインによっては再現できません。
まとめ
- iOS Safari実機は分数pxの
line-heightを整数pxにfloorする(WebKit Bug #225695) mask-image/background-imageの繰り返し罫線で行ごとに0.5px程度の累積誤差が発生し、実用上6〜7行目から目視できる- macOS Safari / Chrome / Firefox / iOSシミュレータ全バージョンで再現せず、iOS実機でだけ発症するため発見が極めて遅れる
- 推奨対処はJSで実描画line-heightを実測 → CSS変数として
maskのストライドに注入。デザインを変えずに全環境を吸収できる
参考リンク
- WebKit Bug #225695 — REGRESSION (Safari 14.1): Fractional CSS line-height value translates to rounded (floored) element height — 本件の根本原因
- WebKit Bug #261463 / #261212 — 関連するline-height丸めissue
- Inconsistent line height in Safari · skosch/Crimson #54 — カスタムフォントでの類似事象
- iOS placeholder line-height with some fonts · necolas/normalize.css #736 — iOS限定のline-height周りの先行事例
- h1 line height different in Safari and Firefox · necolas/normalize.css #593
- Deep dive CSS: font metrics, line-height and vertical-align — Vincent De Oliveira — line-heightとフォントmetricsの深い解説
- CSS Fonts Module Level 4 —
ascent-override/descent-override/line-gap-override - MDN —
line-height - Roel Nieskens — "I wish Safari would support overriding font metrics from CSS"