Vertical Text
Vertical CJK writing mode — right-to-left columns whose length is a character count, short Latin and digit runs stood upright as tate-chū-yoko, kinsoku column breaks, and an automatic fallback to horizontal when the script would not gain from it.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/vertical-text.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VerticalText" component: CJK vertical
writing mode that is actually typeset rather than merely rotated. Nothing beyond
React and a cn() class merger — no measurement, no effects, no state, no
"use client", so the first server-rendered frame is already the final layout.
Contract
- Named export VerticalText, forwardRef<HTMLElement, VerticalTextProps>, props
extend React.HTMLAttributes<HTMLElement>. The copy is `children`; every native
prop not listed below spreads onto the root.
- as?: "div" | "p" | "blockquote" — default "div".
- mode?: "auto" | "vertical" | "horizontal" — default "auto". "auto" measures
the copy (below); the other two take the decision away, which is what a
locale switch or a user setting wants.
- direction?: "rl" | "lr" — default "rl" (vertical-rl). "lr" is the rare
mirror: a Mongolian-style layout, or one vertical column in a Latin page.
- latin?: "combine" | "rotate" | "upright" — default "combine".
- combineUpTo?: number — default 2, rounded and clamped to 1..4.
- columnChars?: number — column length in full-width characters.
- lineHeight?: number | "inherit" — default 1.8. In a vertical flow this is the
COLUMN WIDTH, so it is also the gutter; "inherit" writes nothing.
- lineBreak?: "auto" | "loose" | "normal" | "strict" — default "strict".
- punctuation?: "trim" | "full" — default "trim".
- threshold?: number — default 0.3, clamped to 0..1.
- uprightClassName?: string — extra classes on every combined run.
- Export three pure helpers beside the component, because a consumer needs to ask
the same questions the component asks:
cjkRatio(text) => number | null — the CJK share, or null when there is nothing
to measure; prefersVertical(text, threshold?) => boolean; and
uprightRuns(text, combineUpTo?) => { text: string; upright: boolean }[], which
is the segmentation itself, so a caller can count cells before rendering.
- Root carries data-slot="vertical-text", data-orientation="vertical" |
"horizontal" and data-latin (vertical only). Every combined run carries
data-slot="vertical-text-upright". A test reads the branch off the DOM instead
of measuring pixels.
The geometry — a character count is the only number an author knows
- Turning on writing-mode: vertical-rl swaps the axes. The block's HEIGHT is now
the line length and its WIDTH is the column count; `height` sizes the text and
`line-height` sizes the gutter. Every confusing thing about vertical CSS
follows from that one sentence.
- A full-width CJK character advances exactly 1em in the inline direction. So the
author's real unit — "twelve characters to a column" — is just
height = columnChars em
and the N+1st character starts a new column beside the last one. Write it as a
DEFINITE height, not a max-height: the wrap point is then identical in every
engine, and short copy still occupies the same block. Omit columnChars and the
column length falls to the engine's orthogonal-flow fallback (roughly the
viewport), which is never what anyone meant.
- line-height is the block size of a line box, which in this flow is the column
width: 1.8 gives a column of 1.8em with 0.8em of air beside it. Nothing about
the leading of a horizontal paragraph survives the rotation, so the default is
chosen for gutters, not for reading rhythm.
- Sizing the container is then arithmetic rather than guesswork:
width = ceil(characters / columnChars) x lineHeight em
which is exact with punctuation="full", and an upper bound with "trim".
- Cell costs, for the same arithmetic: a full-width character is 1em, a combined
run is 1em whatever its length, and a rotated Latin run costs its horizontal
advance — about 0.5em per character, so a twenty-character URL eats ten cells.
Behavior — deciding whether to be vertical at all
- mode="auto" concatenates the TOP-LEVEL string children and measures them.
cjkRatio counts Han, kana, Hangul, bopomofo, the CJK and full-width punctuation
and the ideographic extensions as the numerator; the denominator adds every
other letter and digit. Whitespace and ASCII punctuation are in neither, and
U+3000 (the ideographic space) is deliberately excluded — whitespace is
evidence of nothing.
- ratio >= threshold (0.3 by default) sets vertically. A third is low on purpose:
a Japanese sentence quoting a line of English is still Japanese, while an
English sentence carrying one Chinese name is not.
- cjkRatio returns null, NOT 0, when nothing countable was found. The two deserve
opposite defaults: 0 is evidence of Latin copy and refuses; null is no evidence
at all and trusts the caller. That is why a paragraph built entirely of
elements — a poem in three spans — still goes vertical: reaching for a vertical
component is itself the evidence, and refusing it would be a false negative
nobody could debug.
- The refusal is total but narrow: it drops the writing mode, the orientation,
the punctuation spacing, the character-count height and the combined runs, and
keeps the leading and the kinsoku ruleset, because neither of those is
vertical-only. data-orientation says which branch ran.
Behavior — Latin, digits and tate-chū-yoko
- In text-orientation: mixed every Latin letter and digit is rotated onto its
side. That is right for a word and wrong for a year: printed Japanese stands
short runs up in a single cell (縦中横, tate-chū-yoko) and leaves long ones
rotated. So segment, do not blanket-rotate.
- latin="combine" scans for runs of ASCII letters and digits, allowing the
separators that live INSIDE a run (3.5, 1/2, A-1) but never at its edges. A run
of at most combineUpTo characters is wrapped in a span with
text-combine-upright: all; a longer one is left alone, because condensing it
would be unreadable and rotating it is what a typesetter does anyway.
- The clamp to 1..4 is a fact about engines, not taste: past four characters a UA
either overflows the column or gives up and renders the run as-is. Default 2 —
a lone digit and a two-digit number stand up, 100 rotates.
- Build the run scanner as a fresh RegExp per call. A module-scope /g/ literal
carries lastIndex from one render into the next, and two components rendering
at once would read each other's scan position.
- latin="rotate" skips segmentation entirely (no spans, no scan). latin="upright"
sets text-orientation: upright on the block instead — every character stands,
one per cell, which is what a spine, a signboard or a menu column wants and
what body copy never does. Combining is skipped there too: the whole block is
already upright, and layering the two would fight.
- Only top-level strings are segmented. Elements pass through untouched, so
<RubyText>, a <strong> or a link inside the column keeps working.
Behavior — column breaks and punctuation
- line-break is kinsoku shori: which characters may open a column. strict keeps
the small kana (ゃ ゅ ょ っ), the long vowel ー and the closing marks off the
top of one; normal lets the small kana lead; loose is the newspaper ruleset.
Default strict, because a column that opens on a small kana is the single most
visible way vertical text looks machine-set.
- punctuation="full" writes text-spacing-trim: space-all, so every full-width
mark keeps a whole em — the manuscript grid, and the only setting under which
columnChars is exact to the character. "trim" (the default, text-spacing-trim:
normal) lets the engine take the half-em back beside a mark at a column edge or
next to another mark: tighter prose, slightly elastic columns. An engine that
has not implemented the property behaves like "full", so the exact-grid claim
holds everywhere and the tighter one is a progressive enhancement.
- Vertical glyph alternates — the rotated brackets, and 。 and 、 moved to the
corner of the em box — come from the font's own vert/vrt2 features and need no
CSS, but they do need a font that HAS them. Pin a CJK family on the block:
per-character fallback across three families is what makes a vertical page look
assembled rather than set.
Accessibility, keyboard and cleanup
- Keyboard map: none, deliberately. Nothing here is focusable and nothing holds
state — this is body copy, not a control. There is one exception worth wiring:
if the block scrolls, put the overflow ON THE COMPONENT rather than on a
wrapper, because the scroll container's own writing mode is what places the
initial scroll position at the first column (the right edge in vertical-rl); a
horizontal wrapper would open the box at the END of the text. A scroll
container must also be focusable to be scrollable from the keyboard, so add
tabIndex={0}, role="region" and an accessible name — the component takes native
props, so all three are just props.
- No role, no aria-* and no live region otherwise: writing-mode is presentation
and changes neither the DOM order nor the accessibility tree.
- The segmentation inserts element boundaries and NOT ONE character: no character
is added, removed or substituted, so textContent, a selection and a copy all
return the original string. Screen readers are unaffected by any of it.
- Nothing to clean up, and nothing to cancel: no timers, no rAF, no listeners, no
observers, no state, no clock read. No animation either, so
prefers-reduced-motion has nothing to switch off and reduced motion cannot
degrade the layout.
- Guard every number at the door: reject 0, NaN and negatives for lineHeight and
columnChars (a column with no length is not a column), round and clamp
combineUpTo to 1..4, clamp threshold to 0..1.
Rendering & styling
- Semantic tokens only — in fact the component ships no colour at all. Everything
inherits from the surrounding copy, so there is nothing to retheme and dark
mode is free.
- cn() merges className into the root and uprightClassName into every combined
run. The combine rule is written as the arbitrary property
[text-combine-upright:all] rather than an inline style, precisely so
uprightClassName can override it through tailwind-merge — and so Safari before
15.4 can be served -webkit-text-combine: horizontal beside it.
- The vertical branch also sets overflow-wrap: break-word, because a rotated URL
is one unbreakable word inside a ten-character column; break-normal opts out.
- The caller's `style` is spread LAST and therefore wins, including writingMode:
that is the escape hatch for sideways-lr and for anything this component
refuses to do.
- No motion of any kind. Text that animates into place is text you cannot trust
to be there.
Customization levers
- Column length: columnChars is the loud knob — 9-12 for a card, 16-20 for a
page, 24+ for a scroll. Pair it with punctuation="full" when the grid must be
exact, and switch to a max-h-* class instead of the prop when you want a cap
that short copy may undershoot.
- Density: lineHeight is the gutter. 1.5 for a tight poster column, 1.8 for
prose, 2.2 for a classical page with air between the columns.
- Latin policy: latin plus combineUpTo is the whole dial. Raise combineUpTo to 3
or 4 for copy full of three-letter abbreviations, drop it to 1 to stand only
single digits, and reach for latin="upright" for spines and signage.
- Refusal policy: threshold decides how much CJK earns a vertical setting. Lower
it towards 0.1 for a bilingual UI that should stay vertical, raise it towards
0.6 to demand mostly-CJK copy, or bypass detection entirely with mode.
- Colour, family and size ride on className as usual; a CJK family belongs there
too. Style the combined runs from outside with uprightClassName (a condensed
face, tracking-tight) or with [data-slot=vertical-text-upright] when the
classes are not reachable from a global stylesheet.
- Composition: put RubyText, a link or an emphasis element straight in the
children — only top-level strings are segmented, and the writing mode inherits
into whatever you nest.Concepts
- The axes swap — in
vertical-rlthe block's height is the line length and its width is the column count, soheightsizes the text whileline-heightsizes the gutter. Every other confusion about vertical CSS is a consequence of that one exchange. - A column measured in characters — a full-width character advances exactly 1em, so "twelve characters to a column" is literally
height: 12em, and the container's width isceil(characters / columnChars) × lineHeightem. It is written as a definite height rather than a cap so the wrap point is the same in every engine. - Tate-chū-yoko, not blanket rotation —
text-orientation: mixedlays every letter and digit on its side, which is right for a word and wrong for a year. Runs of at mostcombineUpTocharacters are combined upright into one cell; longer runs stay rotated, exactly as a typesetter would leave them. The clamp at four is where engines stop being able to condense. - Kinsoku is a break rule, not a punctuation style —
line-break: strictrefuses to open a column with a small kana, a long vowel mark or a closing bracket;text-spacing-trimdecides separately whether a full-width mark keeps its whole em (an exact grid) or gives half of it back (tighter prose). - Null is not zero — a Latin paragraph measures 0 and is refused; copy with nothing countable in it measures
nulland is trusted, which is why a poem built out of three elements still sets vertically. Collapsing the two would either rotate English or refuse a haiku. - Scroll origin follows the writing mode — a
vertical-rlscroll container opens at its right edge, on the first column; the same overflow moved to a horizontal wrapper opens at the end of the text. Put the overflow on the component, then make it focusable so a keyboard can reach it.
Text Diff
A word- or sentence-level diff of two prose versions — one unified flow or two columns, with insertions and deletions marked by underline, strikethrough and screen-reader wording instead of colour alone.
Annotated Text
Prose with notes attached — marked phrases carry a numbered marker, the note opens on hover or focus, and every note is also listed in full under the passage.