Remotionの縦書き日本語テキスト:writing-modeとSpring
執筆: RenderComp チーム 編集方針
映画のエンドロール、詩集の扉ページ、書道作品を模したモーショングラフィックス。縦書きテキストは日本語動画制作において避けて通れない要素です。HTML と CSS で縦書きレイアウトを作るのは writing-mode: vertical-rl の1行で済みますが、そのテキストをアニメーションさせると途端に罠が現れます。writing-mode が作り出す「論理座標系」と transform が操作する「物理座標系」の食い違いが、思い通りに動かないアニメーションの原因になります。
Remotion はビデオをフレーム番号 f から映像への純粋な関数として扱います。各フレームでコンポーネントが再レンダリングされ、その出力が1枚の静止画として確定します。この決定論的モデルは強力ですが、座標系の食い違いを理解せずに spring や interpolate を縦書きテキストに適用すると、意図とは逆方向に動くアニメーションや崩れたクリップ領域に悩まされます。
本記事では writing-mode の座標系を正確に理解したうえで、文字単位の Spring アニメーション・列クリップリビール・縦中横(tate-chu-yoko)の3パターンを実装します。対象読者は Remotion の useCurrentFrame や spring を使ったことがある React 開発者で、仕様の深い部分から実践コードまでを扱います。
writing-mode の座標系を把握する
vertical-rl と vertical-lr の違い
CSS の縦書きには主に2つのモードがあります。
| 値 | 文字の流れ | 列の流れ | 主な用途 |
|---|---|---|---|
vertical-rl | 上 → 下 | 右 → 左 | 日本語・中国語の縦書き |
vertical-lr | 上 → 下 | 左 → 右 | モンゴル語・一部の現代的レイアウト |
日本語の伝統的縦書きは vertical-rl です。新聞・書籍・映像タイトルカードはすべてこのモードに相当します。
text-orientation で文字の向きを制御する
writing-mode: vertical-rl を設定すると、CJK文字(漢字・ひらがな・カタカナ)は正立します。一方、ASCII 文字や数字は時計回りに90°回転した横向きで表示されます。これを制御するのが text-orientation です。
text-orientation: mixed; /* デフォルト: CJK正立・ASCII横向き */
text-orientation: upright; /* すべての文字を正立 */
text-orientation: sideways; /* すべての文字を横向きに */
実務では mixed がほぼ標準です。英単語を縦書きに混在させる演出のときだけ upright を意識的に選択します。
transform は物理座標系で動く
ここが最大の落とし穴です。 writing-mode を設定しても、CSS の transform プロパティが使う座標系は変わりません。translateX(20px) は常に画面の右方向、translateY(20px) は常に画面の下方向に要素を移動させます。
vertical-rl の論理方向と物理軸の対応は次のとおりです。
- テキストの「インライン方向」(文字が並ぶ方向)= 画面の Y 軸(上下)
- テキストの「ブロック方向」(列が並ぶ方向)= 画面の X 軸(左右)
この対応が理解できると、アニメーションの実装方針が明確になります。文字の流れに沿った動き(文字が「落ちてくる」)は translateY。列単位の動き(列が「右から現れる」)は translateX です。
基本コンポジションのセットアップ
Remotion プロジェクトで縦書きテキストを扱う最小構成から始めます。フォントはシステムフォントを使います。レンダリングサーバーに日本語フォントが存在しない環境では、woff2 ファイルを public/ フォルダに配置し、@font-face で読み込んでください。
// src/VerticalTextBase.tsx
import { AbsoluteFill } from 'remotion';
export const VerticalTextBase: React.FC = () => {
return (
<AbsoluteFill
style={{
backgroundColor: '#0d0d0d',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<p
style={{
writingMode: 'vertical-rl',
textOrientation: 'mixed',
fontFamily: '"Yu Mincho", "Hiragino Mincho ProN", "Noto Serif JP", serif',
fontSize: 64,
color: '#f0ece4',
// vertical-rl では line-height が列の幅(横方向間隔)を制御する
lineHeight: 1.8,
letterSpacing: '0.08em',
margin: 0,
}}
>
風薫る初夏の朝
</p>
</AbsoluteFill>
);
};
vertical-rl での line-height は列と列の間隔(横方向)を制御します。水平書きの行間隔とは意味が90°変わるため、値を調整するときは Remotion の Studio でレンダリング結果を直接確認するのが確実です。
文字単位の Spring アニメーション
設計方針
縦書きテキストの文字を1文字ずつ「上から落として」表示するパターンを実装します。各文字の動きは次のとおりです。
- 初期状態は最終位置より上
−30px(translateY: -30)でopacity: 0にする - 終了状態は最終位置(
translateY: 0)かつopacity: 1にする springで自然な減衰をかけて補間する
前述のとおり、縦書きで「文字の流れに沿った方向」は物理的な Y 軸です。文字が「上から降りてくる」ので translateY に負のオフセットを与えます。
AnimatedChar コンポーネント
// src/components/AnimatedChar.tsx
import React from 'react';
import { useCurrentFrame, useVideoConfig, spring } from 'remotion';
interface AnimatedCharProps {
children: React.ReactNode;
/** アニメーション開始フレーム(Sequence 内では 0 を渡す) */
startFrame: number;
}
export const AnimatedChar: React.FC<AnimatedCharProps> = ({ children, startFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// startFrame より前は spring が 0 を返すため透明・オフセット状態で静止する
const progress = spring({
frame: frame - startFrame,
fps,
config: {
damping: 16, // 減衰係数: 小さいほど跳ねる。16 は自然な落下感
stiffness: 120, // バネ係数: 大きいほど速く収束する
mass: 0.6, // 質量: 小さいほど軽快に動く
},
});
return (
<span
style={{
display: 'inline-block',
opacity: progress,
// vertical-rl での文字フロー方向は Y 軸なので translateY で制御
transform: `translateY(${(1 - progress) * -30}px)`,
}}
>
{children}
</span>
);
};
Sequence でスタガーを組む
各文字を Sequence でラップし、from プロパティをずらすとキャラクターごとの開始フレームを宣言的に制御できます。
// src/VerticalReveal.tsx
import React from 'react';
import { AbsoluteFill, Sequence } from 'remotion';
import { AnimatedChar } from './components/AnimatedChar';
const TEXT = '風薫る初夏の朝';
const STAGGER = 5; // 30fps で約 0.17 秒の文字間遅延
export const VerticalReveal: React.FC = () => {
return (
<AbsoluteFill
style={{
backgroundColor: '#0d0d0d',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
writingMode: 'vertical-rl',
textOrientation: 'mixed',
fontFamily: '"Yu Mincho", "Hiragino Mincho ProN", serif',
fontSize: 64,
color: '#f0ece4',
letterSpacing: '0.1em',
}}
>
{TEXT.split('').map((char, i) => (
// layout="none" を指定しないと Sequence が AbsoluteFill になりレイアウトが崩れる
<Sequence key={i} from={i * STAGGER} layout="none">
<AnimatedChar startFrame={0}>{char}</AnimatedChar>
</Sequence>
))}
</div>
</AbsoluteFill>
);
};
layout="none" は見落としがちな重要オプションです。デフォルトでは Sequence は AbsoluteFill(position: absolute; width: 100%; height: 100%)として振る舞います。インラインの span を包む場合は必ず layout="none" を指定し、通常のインライン要素として扱わせてください。
列単位のクリップリビール
文章を複数列に分け、列ごとに右から左へスライドインさせるパターンです。書道作品や詩的なタイトルカードに多用されます。
vertical-rl では列が右から左に並びます。「最初の列(右端)→ 次の列(その左)」という読み順で各列を translateX でスライドインさせます。
// src/ColumnReveal.tsx
import React from 'react';
import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig, spring } from 'remotion';
const COLUMNS = ['万緑叢中', '紅一点', '動人春色', '不須多'];
const COLUMN_DELAY = 18; // 列間の遅延フレーム数(30fps で 0.6 秒)
const AnimatedColumn: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const slideProgress = spring({
frame,
fps,
config: {
damping: 20,
stiffness: 90,
mass: 1,
},
});
return (
<div
style={{
overflow: 'hidden',
// 列が右から現れる: X軸の正方向(右)から 0 へスライド
transform: `translateX(${(1 - slideProgress) * 80}px)`,
opacity: slideProgress,
}}
>
<div
style={{
writingMode: 'vertical-rl',
textOrientation: 'mixed',
fontFamily: '"Yu Mincho", "Hiragino Mincho ProN", serif',
fontSize: 56,
color: '#f0ece4',
letterSpacing: '0.12em',
padding: '0 16px',
}}
>
{text}
</div>
</div>
);
};
export const ColumnReveal: React.FC = () => {
return (
<AbsoluteFill
style={{
backgroundColor: '#0d0d0d',
// row-reverse で DOM 順(i=0 が右端)を vertical-rl の読み順に合わせる
display: 'flex',
flexDirection: 'row-reverse',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
}}
>
{COLUMNS.map((col, i) => (
<Sequence key={i} from={i * COLUMN_DELAY} layout="none">
<AnimatedColumn text={col} />
</Sequence>
))}
</AbsoluteFill>
);
};
flexDirection: 'row-reverse' が重要です。通常の flex-direction: row では複数の縦書きブロックが左から右に並んでしまい、日本語の読み順(右の列が先)と逆になります。row-reverse を使うことで DOM インデックス i=0 が視覚的に右端に配置され、Sequence の from={i * COLUMN_DELAY} と組み合わせると「右の列から順に現れる」正しいアニメーションになります。
縦中横(tate-chu-yoko)
縦書きテキストに年号・数字・アルファベット略語を混在させると、デフォルトでは各文字が横倒しになります(text-orientation: mixed の仕様)。2文字以内の数字などを縦書き中で水平に並べる縦中横は text-combine-upright プロパティで実現します。
// src/components/TateChuYoko.tsx
import React from 'react';
export const TateChuYoko: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span style={{ textCombineUpright: 'all' }}>{children}</span>
);
縦中横を含む文字列をアニメーションさせる場合、文字列を単純に .split('') すると縦中横セグメントが分断されます。テキストをセグメント配列として管理し、AnimatedChar が children を受け取る設計にするとすっきり解決します。
// src/VerticalDateReveal.tsx
import React from 'react';
import { AbsoluteFill, Sequence } from 'remotion';
import { AnimatedChar } from './components/AnimatedChar';
import { TateChuYoko } from './components/TateChuYoko';
type Segment =
| { type: 'char'; value: string }
| { type: 'tcy'; value: string };
const SEGMENTS: Segment[] = [
{ type: 'char', value: '令' },
{ type: 'char', value: '和' },
{ type: 'tcy', value: '7' },
{ type: 'char', value: '年' },
{ type: 'tcy', value: '8' },
{ type: 'char', value: '月' },
{ type: 'tcy', value: '16' },
{ type: 'char', value: '日' },
];
const STAGGER = 6;
export const VerticalDateReveal: React.FC = () => {
return (
<AbsoluteFill
style={{
backgroundColor: '#0d0d0d',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
writingMode: 'vertical-rl',
fontFamily: '"Yu Mincho", "Hiragino Mincho ProN", serif',
fontSize: 52,
color: '#f0ece4',
letterSpacing: '0.1em',
}}
>
{SEGMENTS.map((seg, i) => (
<Sequence key={i} from={i * STAGGER} layout="none">
<AnimatedChar startFrame={0}>
{seg.type === 'tcy' ? (
<TateChuYoko>{seg.value}</TateChuYoko>
) : (
seg.value
)}
</AnimatedChar>
</Sequence>
))}
</div>
</AbsoluteFill>
);
};
text-combine-upright: all は仕様上1〜2文字を対象としています。3文字以上を指定するとブラウザが縦方向に圧縮して表示しようとし、読みにくくなります。4桁の西暦(例:2026年)は縦中横ではなく、数字部分のフォントサイズを小さくして別スタイルで対処するのが実務的な判断です。
よくある落とし穴まとめ
line-height の意味が変わる
水平書きでは行間隔を制御しますが、vertical-rl では列の幅(横方向の間隔)を制御します。列が想定より広く/狭くなったときは line-height を確認してください。
letter-spacing の末尾余白
letter-spacing は最後の文字にも同量の余白を追加します。縦書きで上下揃えをするとき、最下部の文字の下に意図しないスペースが生じます。margin-bottom: -0.1em などで打ち消す方法が有効です。
text-align の意味が変わる
vertical-rl では text-align: start が列の上揃え、text-align: end が列の下揃えになります。水平書きの左右揃えとは方向が90°変わっている点を意識してください。
句読点・括弧の縦書きグリフ
日本語の縦書き句読点(。「」など)は writing-mode: vertical-rl でブラウザが自動的に正しい向きに回転します。これは OpenType GSUB 機能によるものであり、Remotion のヘッドレス Chrome も同じ挙動を示します。ただしフォントによっては縦書き用グリフが存在せず、横書きグリフが横倒しになることがあります。納品前にレンダリング済みの静止画で必ず確認してください。
Wrapping Up
writing-mode: vertical-rl で縦書きテキストを作ることと、そのテキストを Remotion でアニメーションさせることは、同じ CSS の上に立ちながらも異なるメンタルモデルを要求します。重要なポイントをまとめます。
- transform の座標系は writing-mode に影響されない。 縦書きのフロー方向(上→下)は物理的な Y 軸に対応するため、文字の「流れに沿ったアニメーション」は
translateYで実装する。 - 列のアニメーションは
translateX。vertical-rlでは列が右から左に並ぶため、「列が右から現れる」スライドインはtranslateXを正の値から 0 へ変化させる。 Sequence+layout="none"でインラインスタガーを実現する。 文字単位の遅延はfromプロパティで宣言的に制御し、インラインレイアウトを壊さないためにlayout="none"を必ず指定する。- 縦中横は
text-combine-upright: all。 2文字以内の数字・略語に限り使い、3文字以上はレイアウトで対処する。セグメント配列パターンでAnimatedCharと組み合わせると文字単位アニメーションとの併用が容易になる。 - フォントの縦書きグリフは必ず確認する。 Chrome の GSUB 機能に依存するため、使用フォントが縦書きグリフを持つかどうかをレンダリング結果で目視確認する工程を省かない。
複数列の段違いリビールや毛筆ストロークの描画アニメーションとの組み合わせといった発展パターンは、RenderComp のテンプレートカタログに完成済みのコンポジションとして収録しています。コードをそのまま読み、自分のプロジェクトへ応用するベースとして活用してください。