この文庫は、既定で縦書き表示です。作品ページを開くと、右から左へ本文が流れます。

CSSの writing-mode: vertical-rl を指定すれば縦書きにはなります。しかし、それだけでは読書体験としては成立しませんでした。実際に作って直したものを、順に書きます。

縦書きにするだけなら一行

.honbun {
  writing-mode: vertical-rl;
  text-orientation: mixed;
  height: min(74vh, 760px);
  overflow-x: auto;
  overflow-y: hidden;
}

縦書きでは、本文は横方向に伸びていきます。だから高さを固定して、横スクロールさせます。ここまでは素直です。

問題はこの先でした。

落とし穴1:マウスホイールが効かない

縦書きなのに、マウスホイールは縦にしか回りません。読者は縦に回して、横に進みたい。

そこで、ホイールの縦移動を横スクロールに変換します。

// これは動かない
function onWheel(e) {
  e.preventDefault();          // ← 効かない
  stage.scrollLeft -= e.deltaY;
}
return <div onWheel={onWheel}>…</div>

書いた当初、横には動くのにページ全体も一緒に縦スクロールしてしまうという症状が出ました。preventDefault() を呼んでいるのに止まらない。

原因は、React が wheel を passive リスナーとして登録していることでした。passive なリスナーでは preventDefault() が無視されます。

ネイティブのリスナーを、明示的に非passiveで張れば解決します。

useEffect(() => {
  const stage = stageRef.current;
  function onWheel(e) {
    const max = stage.scrollWidth - stage.clientWidth;
    if (max <= 0) return;
    const delta = e.deltaY || e.deltaX;
    if (delta === 0) return;

    const before = stage.scrollLeft;
    stage.scrollLeft -= delta;      // ブラウザが端でクランプする

    // 実際に動いたときだけ既定動作を止める
    if (stage.scrollLeft !== before) e.preventDefault();
  }
  stage.addEventListener('wheel', onWheel, { passive: false });
  return () => stage.removeEventListener('wheel', onWheel);
}, [orientation]);

ポイントは最後の三行です。**「実際にスクロール位置が変わったときだけ preventDefault() する」**という判定を入れています。

こうすると、本文の途中ではページが縦に動かず、本文の端まで来たらホイールがページに素通しされるという自然な挙動になります。常に止めてしまうと、本文の上でホイールを回してもページを離れられなくなります。

なおこの書き方には副産物があって、scrollLeft の符号を気にしなくて済みます。縦書き(vertical-rl)では scrollLeft が負の値を取りますが、「動いたかどうか」で判定するので符号に依存しません。

落とし穴2:半角の疑問符が横倒しになる

これは実装ではなく、本文のほうの問題でした。

縦書きでは、半角文字は横に倒れて表示されます。 ?! を半角で書いていると、そこだけ寝転がります。日本語の縦組みでは全角の を使わなければいけません。

公開後に気づいて調べたところ、自作の五箇所が半角のままでした。書いた本人には見えない種類の不具合です。

同じ理由で、本文中の半角英数字も避けます。数字は漢数字にすると、縦中横(数字だけ横に組む処理)を考えなくて済みます。「二十二年」と書けば、それ以上何もしなくてよい。

ダッシュも二種類が混在していました。(U+2014)と (U+2015)です。日本語組版では U+2015 を二つ重ねた ―― が標準なので、そちらに統一しました。

こういうものは感覚では直せないので、公開前に機械で数えることにしました。

const RULES = [
  { name: '半角の疑問符・感嘆符', detect: s => (s.match(/[?!]/g) || []).length },
  { name: 'ダッシュ U+2014',      detect: s => (s.match(/—/g) || []).length },
  { name: '本文中の半角英数字',    detect: s => [...new Set(body.match(/[0-9A-Za-z]+/g) || [])].length },
];

違反があれば終了コード1で落ちるようにして、公開前に必ず走らせています。

落とし穴3:スマホでボタンが押せない

読書中の主要な操作は「縦書き/横書きの切り替え」と「文字サイズ」です。

実測したところ、これらのボタンの高さが 28〜31ピクセルしかありませんでした。指で押すには小さすぎます(推奨は44ピクセル)。

見た目のバランスを取ろうとすると、こうなりがちです。デスクトップでは問題なく押せてしまうので、気づきません。

@media (max-width: 560px) {
  .seg button { min-height: 44px; padding-inline: 1.15em; }
  .stepper .step { width: 44px; height: 44px; }
}

モバイルでだけ拡大しました。読書中にいちばん触る場所なので、ここは最優先で確保する価値があります。

落とし穴4:キーボードで送れない

縦書きでは、進む方向が「左」です。直感と逆になるので、明示的に実装します。

if (e.key === 'ArrowLeft' || e.key === ' ' || e.key === 'PageDown') {
  stage.scrollLeft -= page;   // 読み進める
} else if (e.key === 'ArrowRight' || e.key === 'PageUp') {
  stage.scrollLeft += page;   // 戻る
}

あわせて、フォーカスリングを必ず出すこと。キーボードで送れるようにしたのに、いまどこにフォーカスがあるか見えないのでは意味がありません。

:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--shu);
  outline-offset: 3px;
}

:focus-visible に限定すると、マウス操作では出ず、キーボード操作のときだけ出ます。

横書きも用意する。ただし手を抜かない

縦書きが読みにくい環境もあるので、切り替えを用意しています。

横書きのときに効くのは、一行の字数です。日本語では三十五〜四十五字が可読の範囲で、それを超えると視線の戻りがつらくなります。

.honbun.yokogaki {
  max-width: 40em;      /* ≒ 一行40字 */
  line-height: 2.15;
  letter-spacing: 0.03em;
}

em で指定すると、文字サイズを変えても一行の字数が保たれます。実測で一行40字・行間2.15になっていることを確認しました。

ルビと傍点は、記法から作る

青空文庫の記法をそのまま使えるようにしています。

  • ルビ:<ruby>漢字<rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby> → 直前の漢字連続にルビ
  • ルビ(明示):<ruby>任意の語<rp>(</rp><rt>よみ</rt><rp>)</rp></ruby>
  • 傍点:<em class="bouten">強調</em>

Markdown を HTML に変換する前に、正規表現で <ruby> タグに置き換えています。傍点は text-emphasis で、縦書きでは自動的に右側に打たれます。

運用上のルールも決めました。ルビは初出のみ。同じ語に何度も振ると、画面がうるさくなります。傍点は一篇に一〜二箇所。多いと安っぽくなります。

まとめ:縦書きは「見た目」ではなく「操作」の問題だった

writing-mode を書いた時点では、仕事の一割も終わっていませんでした。

残りの九割は、縦書きになったことで壊れる操作を、ひとつずつ直す作業です。ホイールの向き、キーの向き、約物の向き、タップ標的の大きさ、フォーカスの見え方。

どれも派手ではありませんが、これをやらないと「縦書きっぽく表示されるが、読めないページ」になります。

実際に読める状態になったものは、作品ページで試せます。右上のボタンで横書きにも切り替えられます。