Animate Vertical Japanese Text in Remotion with writing-mode CSS
By RenderComp Team Editorial policy
Vertical Japanese text — 縦書き, tategaki — carries a different emotional register than horizontal text. The orientation alone signals a chapter title, a haiku on a scroll, a documentary credit sequence, or a broadcast lower-third in the NHK house style. In Remotion, every rendered frame is a deterministic function of frame and durationInFrames, which means the moment you understand how writing-mode: vertical-rl maps onto the browser’s layout box model, you can spring, interpolate, and sequence vertical text with exactly the same precision you apply to any other composition element.
The difficulty is that vertical text rewires your mental model in ways that silently break assumptions. The block axis and the inline axis swap positions. letter-spacing grows text downward, not rightward. A slide-in animation that correctly enters from off-screen in horizontal text may need to enter from above or from below in vertical text, depending on which axis you’re moving along. Remotion renders inside Chromium, which ships full support for CSS Writing Modes Level 3, so the rendering is reliable. The gotchas are in how you map animation axes and in how several CSS properties change meaning under a rotated writing mode.
This article covers four production patterns: a baseline vertical composition, a per-character spring reveal, tate-chu-yoko inline numbers, and a multi-column side-scroll. All code runs on system fonts with Japanese fallbacks, with no external font imports anywhere.
The writing-mode Box Model, Explained for Remotion
The coordinate system requires attention before any animation work. Under writing-mode: vertical-rl:
- The block axis runs right → left. New columns appear to the left of existing ones.
- The inline axis runs top → bottom. Characters advance downward within a column.
widthandheighton a container retain their physical screen meanings throughout.margin-inline-startmaps tomargin-top.margin-block-startmaps tomargin-right.
vertical-rl versus vertical-lr is the first decision you must make consciously. vertical-rl places the first column on the right edge and pushes subsequent columns leftward, following the traditional Japanese book and scroll convention. vertical-lr inverts that, which is correct for Mongolian script and almost never right for Japanese. Default to vertical-rl.
// Axis summary for vertical-rl:
//
// col3 | col2 | col1 ← block axis runs right→left
// ↓ ↓ ↓ ← inline axis runs top→bottom per column
text-orientation has three values that matter: mixed (the default, which rotates Latin characters 90° so they lie on their side, as is standard in Japanese prose), upright (keeps Latin and digits face-forward), and sideways (rotates everything regardless of script). For Japanese body text that includes occasional ASCII you almost always want mixed. For a counter or a year that must read straight, use tate-chu-yoko, which has its own section below.
Setting Up the Base Composition
A 1080×1920 portrait canvas is the natural home for vertical text: it mirrors a phone screen and approximates a traditional scroll proportion. Here is a minimal composition that renders a vertical title string with no animation yet:
import { AbsoluteFill } from "remotion";
// No external font imports — system stack with Japanese fallbacks.
const FONT_STACK =
'"Yu Mincho", "Hiragino Mincho ProN", "Noto Serif JP", Georgia, serif';
export const VerticalTitle: React.FC<{ text: string }> = ({ text }) => {
return (
<AbsoluteFill
style={{
background: "#0d0d0d",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
writingMode: "vertical-rl",
textOrientation: "mixed",
fontFamily: FONT_STACK,
fontSize: 96,
color: "#f5f0e8",
letterSpacing: "0.15em", // grows downward along the inline axis
lineHeight: 1.4, // controls horizontal gap between columns
}}
>
{text}
</div>
</AbsoluteFill>
);
};
Two properties behave unexpectedly here. First, letterSpacing in vertical mode pushes characters apart along the inline axis, which is now vertical: a positive value adds space below each glyph, not to its right. Second, lineHeight now governs the horizontal spacing between columns rather than vertical rhythm. If a two-column layout feels cramped, increase lineHeight, not letterSpacing. Getting these two backwards is the most common source of “why does my spacing look wrong?” questions with vertical text.
Per-Character Spring Reveal
The most requested animation for vertical Japanese titles: characters drop in from above one at a time, settling into place with a spring. In horizontal text this is a translate along the X axis per character index. In vertical text the same motion lives on the Y axis, and the stagger and spring setup is identical.
Because Remotion is React, split the string into individual characters and wrap each in a positioned <span>:
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
const FONT_STACK =
'"Yu Mincho", "Hiragino Mincho ProN", "Noto Serif JP", Georgia, serif';
const STAGGER_FRAMES = 4; // frames between successive character entrances
const FALL_DISTANCE = -80; // px — negative Y means coming from above
export const CharacterReveal: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Spread over the string — split("") breaks surrogate pairs.
// Japanese ideographs are BMP, but emoji and some historic characters are not.
const chars = [...text];
return (
<AbsoluteFill
style={{
background: "#0d0d0d",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
writingMode: "vertical-rl",
textOrientation: "mixed",
fontFamily: FONT_STACK,
fontSize: 88,
color: "#f5f0e8",
letterSpacing: "0.1em",
display: "flex",
flexDirection: "column", // stacks chars top→bottom within the column
}}
>
{chars.map((char, i) => {
const startFrame = i * STAGGER_FRAMES;
const progress = spring({
frame: frame - startFrame,
fps,
config: {
stiffness: 180, // snappy without jitter at 30 fps
damping: 20, // suppresses overshoot for body-text size
mass: 1,
},
});
const translateY = interpolate(progress, [0, 1], [FALL_DISTANCE, 0]);
const opacity = interpolate(progress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<span
key={i}
style={{
display: "inline-block",
transform: `translateY(${translateY}px)`,
opacity,
// inline-block creates a containing block so transform
// applies to the individual glyph, not the whole column
}}
>
{char}
</span>
);
})}
</div>
</AbsoluteFill>
);
};
The [...text] spread is not optional. Japanese ideographs in the Basic Multilingual Plane are single code units, but emoji and certain historic characters are surrogate pairs: split("") cuts through them and produces garbage glyphs at runtime.
Choosing Spring Parameters for Vertical Text
stiffness: 180 / damping: 20 produces a quick settle with minimal overshoot, which works well for body text at 30 fps where a bouncing character reads as jittery. For a large display title where a visible snap communicates authority, try stiffness: 120 / damping: 14: the extra overshoot reads as weight arriving rather than nervousness. For a broadcast lower-third that must feel tight and controlled, go stiffness: 260 / damping: 28; the character slots in with almost zero settle time.
Entering from Below vs. Above
The direction changes the emotional read. Characters falling from above suggest revelation, rain, or authority, a register common in historical drama title sequences. Characters rising from below suggest emergence or a climax building. The only change is the sign on FALL_DISTANCE:
const FALL_DISTANCE = -80; // above
const FALL_DISTANCE = 80; // below
The interpolate mapping and spring config stay identical. The axis is Y in both cases because you’re moving along the vertical inline axis.
Tate-chu-yoko: Inline Horizontal Numbers
A year like 2026 rotated 90° on its side inside a vertical column is illegible and looks unprofessional. CSS Writing Modes Level 3 provides text-combine-upright: all, the CSS name for tate-chu-yoko (縦中横), the typographic convention of embedding a short horizontal run of characters upright within a vertical line.
Chromium supports text-combine-upright for runs of one to four characters. Beyond four characters the behavior is unspecified, and the browser either overflows or shrinks the glyphs to fit. For a year, four digits fit exactly at the limit:
const FONT_STACK =
'"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif';
export const TateChuYokoDemo: React.FC = () => (
<AbsoluteFill
style={{ background: "#fff", display: "flex", justifyContent: "center", alignItems: "center" }}
>
<p
style={{
writingMode: "vertical-rl",
fontFamily: FONT_STACK,
fontSize: 72,
color: "#111",
margin: 0,
}}
>
令和
<span style={{ textCombineUpright: "all" }}>8</span>
年度
</p>
</AbsoluteFill>
);
text-combine-upright works with dynamically changing content. If you need an animated counter inside a vertical string, keep the <span> wrapper and drive the displayed digits with Math.floor(interpolate(frame, [0, durationInFrames], [0, 100])). Chromium applies the upright rendering at paint time regardless of the text node’s current content, so the tate-chu-yoko treatment follows the number as it counts up.
Multi-Column Side-Scroll
A scroll that reveals successive columns from left to right, the digital equivalent of unrolling a horizontal makimono (巻物), requires clipping the container and translating it horizontally. Because vertical-rl places new columns to the left of existing ones, the reveal direction is right-to-left in screen space: you start the container translated right-off-screen and animate it back to x=0.
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
const FONT_STACK =
'"Yu Mincho", "Hiragino Mincho ProN", "Noto Serif JP", Georgia, serif';
const LINES = [
"春はあけぼの",
"やうやう白くなりゆく",
"山ぎは少し",
"あかりて",
];
const COL_WIDTH = 120; // px — tune to match your font size and lineHeight
const SCROLL_FRAMES = 90;
export const ScrollReveal: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const totalCols = LINES.length;
// Higher mass slows the initial acceleration — makes a long scroll feel
// like a heavy physical object unrolling rather than a card snapping in.
const progress = spring({
frame,
fps,
config: { stiffness: 80, damping: 22, mass: 1.2 },
durationInFrames: SCROLL_FRAMES,
});
const translateX = interpolate(
progress,
[0, 1],
[COL_WIDTH * totalCols, 0] // start fully off the right edge
);
return (
<AbsoluteFill
style={{
background: "#fdf6e3",
display: "flex",
alignItems: "center",
overflow: "hidden", // clips the pre-reveal columns
}}
>
<div
style={{
writingMode: "vertical-rl",
fontFamily: FONT_STACK,
fontSize: 64,
color: "#2c2c2c",
letterSpacing: "0.12em",
lineHeight: 1.8, // wide inter-column gap for airy feel
transform: `translateX(${translateX}px)`,
whiteSpace: "nowrap", // REQUIRED — prevents columns from wrapping
}}
>
{LINES.join("\n")}
</div>
</AbsoluteFill>
);
};
whiteSpace: "nowrap" is mandatory. Without it the browser wraps the vertical text to fit the container width, collapsing all four columns into one tall column. With nowrap, each newline in the string creates a new column and the full multi-column layout extends beyond the viewport, exactly the overflow you need to clip and animate.
Vertical Kerning and Font Feature Settings
One detail that separates production typography from quick demos: vertical kerning. OpenType fonts with vertical metrics include a vkrn feature table that adjusts spacing between specific character pairs in vertical layout. Browsers do not enable this by default. Both Yu Mincho and Noto Serif JP ship with vertical metrics; enable them explicitly:
style={{
writingMode: "vertical-rl",
fontFeatureSettings: '"vkrn" 1, "vpal" 1',
// vkrn: vertical pair kerning
// vpal: proportional alternate widths — fixes punctuation spacing
}}
The difference is most visible around 。(ideographic full stop) and 、(ideographic comma). Without vpal, these glyphs are defined as full-width, leaving a visible gap above the punctuation mark. With vpal active, the browser selects proportional alternate metrics and the punctuation sits flush against surrounding characters. At display sizes above 64 px the difference is immediately legible to any Japanese reader.
Wrapping Up
Vertical Japanese text in Remotion is a first-class typographic option rather than a workaround. The coordinate system swap is the one conceptual hurdle; once it’s internalized, every animation pattern you already know maps cleanly onto the new axes.
Key takeaways:
- Use
vertical-rlrather thanvertical-lrfor all standard Japanese column layouts. - Use
[...text]to spread the string before mapping characters to<span>elements;split("")breaks surrogate pairs. - In vertical mode,
letterSpacingadds space below each glyph (the inline axis is now vertical), whilelineHeightcontrols the horizontal gap between columns. Swapping these two is the most expensive mental-model mistake. text-combine-upright: allhandles tate-chu-yoko for up to four characters; beyond that the behavior is undefined.whiteSpace: "nowrap"is required to prevent multi-column text from collapsing into a single column.- Enable
fontFeatureSettings: '"vkrn" 1, "vpal" 1'to tighten punctuation spacing in OpenType fonts that ship vertical metrics. - Spring parameters that work:
stiffness 180 / damping 20for body text snaps;stiffness 80 / mass 1.2for slow weighted scrolls;stiffness 260 / damping 28for broadcast-tight lower-thirds.
These patterns compose directly with <Sequence> for timing control and interpolateColors for color transitions tied to entrance progress. Templates like the ones in the RenderComp catalog combine vertical title sequences with horizontal lower-thirds; studying them shows how both orientations can share a composition without either fighting for dominance.
The axis swap is the only real conceptual barrier. Once that mapping clicks, vertical Japanese text sits squarely within the same composable, time-indexed model as everything else in your project.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →