📝

Web で縦書きの段組みレイアウトは意外と難しい

2024/08/28に公開
2

概要

Web で文字数が不定の文章を縦書き段組みでいい感じにレイアウトするのは意外と難しい、ということを紹介する記事です。[1]

環境

  • Next.js App Router: v14.2.5
    • React を簡単にセットアップしたかっただけで、ほとんど関係ないです
  • Windows 11 Google Chrome: v127.0.6533.120

実装

今回は「小説のような長さ不定の文章を、縦書きかつ縦スクロールで読みたい」という要望に応えることを想定します。
長い文章を縦書きかつ縦スクロールで読むには、読みやすさの観点で段組みするしかないと思います。

また一般の Web サイトにはヘッダーやフッターがあるので、最低限それらを模したレイアウトに収めることを目標にします。

縦書きで段組みする

まず共通パーツとして夏目漱石の小説『こころ』の中身を詰め込んだコンポーネントを作っておきます。(中身はどうでもいいので読まなくていいです。)

src/app/_components/Contents.tsx
export function Contents() {
  return (
    <div
      dangerouslySetInnerHTML={{
        __html:
          "<p><ruby><rb>私</rb><rp>(</rp><rt>わたくし</rt><rp>)</rp></ruby>はその人を常に先生と呼んでいた。だからここでもただ先生と書くだけで本名は打ち明けない。これは世間を<ruby><rb>憚</rb><rp>(</rp><rt>はば</rt><rp>)</rp></ruby>かる遠慮というよりも、その方が私にとって自然だからである。私はその人の記憶を呼び起すごとに、すぐ「先生」といいたくなる。筆を<ruby><rb>執</rb><rp>(</rp><rt>と</rt><rp>)</rp></ruby>っても心持は同じ事である。よそよそしい<ruby><rb>頭文字</rb><rp>(</rp><rt>かしらもじ</rt><rp>)</rp></ruby>などはとても使う気にならない。<br/>私が先生と知り合いになったのは<ruby><rb>鎌倉</rb><rp>(</rp><rt>かまくら</rt><rp>)</rp></ruby>である。その時私はまだ若々しい書生であった。暑中休暇を利用して海水浴に行った友達からぜひ来いという<ruby><rb>端書</rb><rp>(</rp><rt>はがき</rt><rp>)</rp></ruby>を受け取ったので、私は多少の金を<ruby><rb>工面</rb><rp>(</rp><rt>くめん</rt><rp>)</rp></ruby>して、出掛ける事にした。私は金の工面に<ruby><rb>二</rb><rp>(</rp><rt>に</rt><rp>)</rp></ruby>、<ruby><rb>三日</rb><rp>(</rp><rt>さんち</rt><rp>)</rp></ruby>を費やした。ところが私が鎌倉に着いて三日と<ruby><rb>経</rb><rp>(</rp><rt>た</rt><rp>)</rp></ruby>たないうちに、私を呼び寄せた友達は、急に国元から帰れという電報を受け取った。電報には母が病気だからと断ってあったけれども友達はそれを信じなかった。友達はかねてから国元にいる親たちに<ruby><rb>勧</rb><rp>(</rp><rt>すす</rt><rp>)</rp></ruby>まない結婚を<ruby><rb>強</rb><rp>(</rp><rt>し</rt><rp>)</rp></ruby>いられていた。彼は現代の習慣からいうと結婚するにはあまり年が若過ぎた。それに<ruby><rb>肝心</rb><rp>(</rp><rt>かんじん</rt><rp>)</rp></ruby>の当人が気に入らなかった。それで夏休みに当然帰るべきところを、わざと避けて東京の近くで遊んでいたのである。彼は電報を私に見せてどうしようと相談をした。私にはどうしていいか分らなかった。けれども実際彼の母が病気であるとすれば彼は<ruby><rb>固</rb><rp>(</rp><rt>もと</rt><rp>)</rp></ruby>より帰るべきはずであった。それで彼はとうとう帰る事になった。せっかく来た私は一人取り残された。<br/>学校の授業が始まるにはまだ<ruby><rb>大分</rb><rp>(</rp><rt>だいぶ</rt><rp>)</rp></ruby><ruby><rb>日数</rb><rp>(</rp><rt>ひかず</rt><rp>)</rp></ruby>があるので鎌倉におってもよし、帰ってもよいという境遇にいた私は、当分元の宿に<ruby><rb>留</rb><rp>(</rp><rt>と</rt><rp>)</rp></ruby>まる覚悟をした。友達は中国のある資産家の<ruby><rb>息子</rb><rp>(</rp><rt>むすこ</rt><rp>)</rp></ruby>で金に不自由のない男であったけれども、学校が学校なのと年が年なので、生活の程度は私とそう変りもしなかった。したがって<ruby><rb>一人</rb><rp>(</rp><rt>ひとり</rt><rp>)</rp></ruby>ぼっちになった私は別に<ruby><rb>恰好</rb><rp>(</rp><rt>かっこう</rt><rp>)</rp></ruby>な宿を探す面倒ももたなかったのである。<br/>宿は鎌倉でも<ruby><rb>辺鄙</rb><rp>(</rp><rt>へんぴ</rt><rp>)</rp></ruby>な方角にあった。<ruby><rb>玉突</rb><rp>(</rp><rt>たまつ</rt><rp>)</rp></ruby>きだのアイスクリームだのというハイカラなものには長い<ruby><rb>畷</rb><rp>(</rp><rt>なわて</rt><rp>)</rp></ruby>を一つ越さなければ手が届かなかった。車で行っても二十銭は取られた。けれども個人の別荘はそこここにいくつでも建てられていた。それに海へはごく近いので海水浴をやるには至極便利な地位を占めていた。<br/>私は毎日海へはいりに出掛けた。古い<ruby><rb>燻</rb><rp>(</rp><rt>くす</rt><rp>)</rp></ruby>ぶり返った<ruby><rb>藁葺</rb><rp>(</rp><rt>わらぶき</rt><rp>)</rp></ruby>の<ruby><rb>間</rb><rp>(</rp><rt>あいだ</rt><rp>)</rp></ruby>を通り抜けて<ruby><rb>磯</rb><rp>(</rp><rt>いそ</rt><rp>)</rp></ruby>へ下りると、この<ruby><rb>辺</rb><rp>(</rp><rt>へん</rt><rp>)</rp></ruby>にこれほどの都会人種が住んでいるかと思うほど、避暑に来た男や女で砂の上が動いていた。ある時は海の中が<ruby><rb>銭湯</rb><rp>(</rp><rt>せんとう</rt><rp>)</rp></ruby>のように黒い頭でごちゃごちゃしている事もあった。その中に知った人を一人ももたない私も、こういう<ruby><rb>賑</rb><rp>(</rp><rt>にぎ</rt><rp>)</rp></ruby>やかな景色の中に<ruby><rb>裹</rb><rp>(</rp><rt>つつ</rt><rp>)</rp></ruby>まれて、砂の上に<ruby><rb>寝</rb><rp>(</rp><rt>ね</rt><rp>)</rp></ruby>そべってみたり、<ruby><rb>膝頭</rb><rp>(</rp><rt>ひざがしら</rt><rp>)</rp></ruby>を波に打たしてそこいらを<ruby><rb>跳</rb><rp>(</rp><rt>は</rt><rp>)</rp></ruby>ね<ruby><rb>廻</rb><rp>(</rp><rt>まわ</rt><rp>)</rp></ruby>るのは愉快であった。<br/>私は実に先生をこの<ruby><rb>雑沓</rb><rp>(</rp><rt>ざっとう</rt><rp>)</rp></ruby>の<ruby><rb>間</rb><rp>(</rp><rt>あいだ</rt><rp>)</rp></ruby>に見付け出したのである。その時海岸には<ruby><rb>掛茶屋</rb><rp>(</rp><rt>かけぢゃや</rt><rp>)</rp></ruby>が二軒あった。私はふとした<ruby><rb>機会</rb><rp>(</rp><rt>はずみ</rt><rp>)</rp></ruby>からその一軒の方に行き<ruby><rb>慣</rb><rp>(</rp><rt>な</rt><rp>)</rp></ruby>れていた。<ruby><rb>長谷辺</rb><rp>(</rp><rt>はせへん</rt><rp>)</rp></ruby>に大きな別荘を構えている人と違って、<ruby><rb>各自</rb><rp>(</rp><rt>めいめい</rt><rp>)</rp></ruby>に専有の<ruby><rb>着換場</rb><rp>(</rp><rt>きがえば</rt><rp>)</rp></ruby>を<ruby><rb>拵</rb><rp>(</rp><rt>こしら</rt><rp>)</rp></ruby>えていないここいらの避暑客には、ぜひともこうした共同着換所といった<ruby><rb>風</rb><rp>(</rp><rt>ふう</rt><rp>)</rp></ruby>なものが必要なのであった。彼らはここで茶を飲み、ここで休息する<ruby><rb>外</rb><rp>(</rp><rt>ほか</rt><rp>)</rp></ruby>に、ここで海水着を洗濯させたり、ここで<ruby><rb>鹹</rb><rp>(</rp><rt>しお</rt><rp>)</rp></ruby>はゆい<ruby><rb>身体</rb><rp>(</rp><rt>からだ</rt><rp>)</rp></ruby>を清めたり、ここへ帽子や<ruby><rb>傘</rb><rp>(</rp><rt>かさ</rt><rp>)</rp></ruby>を預けたりするのである。海水着を持たない私にも持物を盗まれる恐れはあったので、私は海へはいるたびにその茶屋へ<ruby><rb>一切</rb><rp>(</rp><rt>いっさい</rt><rp>)</rp></ruby>を<ruby><rb>脱</rb><rp>(</rp><rt>ぬ</rt><rp>)</rp></ruby>ぎ<ruby><rb>棄</rb><rp>(</rp><rt>す</rt><rp>)</rp></ruby>てる事にしていた。</p>",
      }}
    />
  );
}

ページの中身はシンプルに header, main, footer を縦に積みたいと思います。

app/page.tsx
import { Contents } from "../_components/Contents";
import style from "./style.module.css";

export default function Page() {
  return (
    <>
      <header className={style.header}>ヘッダー</header>
      <main className={style.main}>
        <div className={style.contents}>
          <Contents />
        </div>
      </main>
      <footer className={style.footer}>フッター</footer>
    </>
  );
}

CSS は CSS Modules で書きます。
コンテンツの最大幅を 600px とし、縦書きにして、段の高さなどを指定しておきます。

app/style.module.css
.contents {
    margin: 0 auto;
    width: 100%;
    max-width: 600px;
    writing-mode: vertical-rl;
    column-width: 15rem;
    column-rule: solid 1px #ccc;
    column-gap: 2.5rem;
}

.main {
    padding: 0 16px;
}

.header, .footer {
    padding: 8px;
    background-color: gray;
}

これで描画した結果が下記です。

開幕から雲行きが怪しいですね。
なぜかフッターがコンテンツの途中に出現しています。

どうなっているかというと、文章がコンテンツ部分の領域をはみ出して表示されています。

フッターの位置はコンテンツ部分の高さの影響を受け、文章の途中に乗っかってしまうという状態です。

コンテンツの高さをセットしてあげればいい?

コンテンツ部分の scrollHeight はきちんと計算されていました。
それなら scrollHeight をコンテンツ部分の高さに指定してあげれば、フッターが正しい位置に収まるのではないでしょうか?

app/page.tsx
  import { Contents } from "../_components/Contents";
  import style from "./style.module.css";

  export default function Page() {
+   const contentsRef = useRef<HTMLDivElement | null>(null);
+   const [height, setHeight] = useState<number>();
+   const ticking = useRef(false);
+   useEffect(() => {
+     if (!contentsRef.current) return;
+ 
+     const contentsElement = contentsRef.current;
+     const setContentsHeight = () => {
+       ticking.current = false;
+       setHeight(contentsElement.scrollHeight);
+     };
+     const onResize = () => {
+       if (ticking.current) return;
+       requestAnimationFrame(setContentsHeight);
+       ticking.current = true;
+     };
+ 
+     onResize();
+ 
+     window.addEventListener("resize", onResize, { passive: true });
+     return () => window.removeEventListener("resize", onResize);
+   }, []);

    return (
      <>
        <header className={style.header}>ヘッダー</header>
        <main className={style.main}>
+         <div className={style.contents} ref={contentsRef} style={{ height }}>
-         <div className={style.contents}>
            <Contents />
          </div>
        </main>
        <footer className={style.footer}>フッター</footer>
      </>
    );
  }

ブラウザをリサイズすると scrollHeight が変わるので、resize イベントでフッターの位置を再計算するようにしています。
結果、下記のようになります。

一見うまくいったようですが、文章が全体的に右に寄っています。

これは CSS の段組みの仕様による挙動になります。

https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_multicol_layout/Using_multicol_layouts#height_balancing

CSS columns require that the column heights must be balanced: that is, the browser automatically sets the maximum column height so that the heights of the content in each column are approximately equal.
(中略)
if the height is constrained, by setting the CSS height or max-height properties on a multi-column block, each column is allowed to grow to that height and no further before adding new column.

今は縦書きにしているので、上記の説明の高さと幅を読みかえてください。

つまり通常 CSS の段組みでは、すべての段の文章がなるべく均等になるように文章が配置されます。

一方、段組み部分の高さ(縦書きでは幅)を指定することで、均等になるように文章を割り当てる挙動ではなく、高さ(縦書きでは幅)いっぱいに文章を流し込んだら次の段へ……という挙動をしてくれるようになります。
最初の例はこの挙動によって、文章が足りなくなるまでは幅いっぱいに文章が配置されていたわけです。

今回のように段組み部分の高さと幅を両方指定した場合、少なくとも Google Chrome では通常の挙動(文章を均等に割り当てる)を優先するようです。
先ほど文章が全体的に右に寄ってしまったのは、文章を均等に配置した結果、コンテンツの幅より文章の幅が小さくなったからです。

2024/08/31 追記:column-fill: auto にする

コメントで教えていただきました。column-fill プロパティを変更すれば、均等に文章を割り当てる挙動から変更可能です。
https://zenn.dev/link/comments/b9087307b73212

https://developer.mozilla.org/ja/docs/Web/CSS/column-fill

というわけで、scroll-height をコンテンツの高さに指定しつつ、column-fill: auto を指定してみます。

app/style.module.css
  .contents {
      margin: 0 auto;
      width: 100%;
      max-width: 600px;
      writing-mode: vertical-rl;
      column-width: 15rem;
      column-rule: solid 1px #ccc;
      column-gap: 2.5rem;
+     column-fill: auto;
  }

  /* 以下略 */

結果は以下のようになりました。

動作確認は基本的に Chrome で行っておりますが、実は今回の指定は Edge だとうまくいきました。
Firefox もうまくいきました。(というか Firefox では column-fill 未指定でもうまくいきます。)
また Chrome でもブラウザをリサイズするとフッターの位置が変わって正しい位置になります。

あまり深追いできていませんが、Chrome だと scrollHeight をセットした後に文章の配置の変更が起こって scrollHeight が再度変わっているのかもしれません……。

また余談ですが Chrome では段の高さの計算も少し違うようです。下図の左がコンテンツの高さと column-fill: auto を指定したとき。右がコンテンツの高さを指定せず column-fill は初期値の balance のときです。

段の高さは column-width で指定していますが、これはあくまでも理想的な高さを指定しているにすぎず、保証されるのは段の高さがこれより小さくならないことだけです。

https://developer.mozilla.org/en-US/docs/Web/CSS/column-width

コンテンツの高さと column-fill: auto を指定したときと、コンテンツの高さを指定せず column-fill を指定しなかったときは同じにはならないことがわかりました。

position: static は諦める

コンテンツの高さを指定する方法は少なくとも Chrome だと難しかったので、安直な解決方法として、position: absolute でフッターを配置します。

app/page.tsx
  import { Contents } from "../_components/Contents";
  import style from "./style.module.css";

  export default function Page() {
+   const headerRef = useRef<HTMLDivElement | null>(null);
+   const contentsRef = useRef<HTMLDivElement | null>(null);
+   const footerRef = useRef<HTMLDivElement | null>(null);
+   const [footerTop, setFooterTop] = useState<number>();
+   const ticking = useRef(false);
+   useEffect(() => {
+     if (!contentsRef.current || !headerRef.current) return;
+ 
+     const contentsElement = contentsRef.current;
+     const headerElement = headerRef.current;
+     const setFooter = () => {
+       ticking.current = false;
+       setFooterTop(headerElement.offsetHeight + contentsElement.scrollHeight);
+     };
+     const onResize = () => {
+       if (ticking.current) return;
+       requestAnimationFrame(setFooter);
+       ticking.current = true;
+     };
+ 
+     onResize();
+ 
+     window.addEventListener("resize", onResize, { passive: true });
+     return () => window.removeEventListener("resize", onResize);
+   }, []);

    return (
      <>
+       <header className={style.header} ref={headerRef}>
+         ヘッダー
+       </header>
-       <header className={style.header}>ヘッダー</header>
        <main className={style.main}>
+         <div className={style.contents} ref={contentsRef}>
-         <div className={style.contents}>
            <Contents />
          </div>
        </main>
+       <footer
+         className={style.footer}
+         ref={footerRef}
+         style={
+           footerTop
+             ? {
+                 position: "absolute",
+                 insetInline: "0",
+                 insetBlockStart: footerTop,
+               }
+             : undefined
+         }
+       >
+         フッター
+       </footer>
-       <footer className={style.footer}>フッター</footer>
      </>
    );
  }

ヘッダーの offsetHeight とコンテンツ部分の scrollHeight を足してフッターの位置を決定します。

これでコンテンツの下にフッターを配置できました。

おわりに

CSS の段組みは幅や高さの指定の仕方、column-fill の指定などによって異なる挙動を示します。
Chrome と Edge のような比較的近い挙動を示すブラウザ間でも挙動が異なる場合があります。
更に縦書きを組み合わせると、通常は簡単な「ただ縦に積み上げる」というだけのレイアウトが意外と難しくなる場合があります。
もし「縦書きで段組みにしたい」と相談されたときは安請け合いせず、実現可能性をきちんと調べることをおすすめします。

脚注
  1. 2019年12月、バックエンドエンジニアだったときに同様の記事を書きましたが、それのリベンジでもあります。
    https://blog.84b9cb.info/posts/css-for-japanese-web-novels/
    当時は JavaScript で何とかするという発想が無く上手くできませんでしたが、今回は何とかできました。
    また当時は「ルビだけ前の段に置き去りにされる」といった挙動もありましたが、大変ありがたいことに直っていました。 ↩︎

chot Inc. tech blog

Discussion

hidarumahidaruma

つまり通常 CSS の段組みでは、すべての段の文章がなるべく均等になるように文章が配置されます。

CSS部を見る限り、column-fillが初期値のbalanceになっているからかなと思うのですが、どうでしょうか。column-fill: auto;のとき横組でも先の段から埋めていくので、縦組でもそのような挙動となると思われます。

k1350k1350

ありがとうございます!
column-fill: auto; なら確かに挙動が変わったので、試してみた結果を記事に反映しました!