Text

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.

Preview in your theme

Loading preview…

import * as React from "react"

import { cn } from "@/lib/utils"

/** Rendered element. Body copy is a paragraph; a column of verse is usually a div. */
export type VerticalTextTag = "div" | "p" | "blockquote"

/**
 * Which way the columns stack.
 * `rl` — right to left, `writing-mode: vertical-rl`: Japanese, traditional
 * Chinese, classical Korean. This is what "vertical text" means almost everywhere.
 * `lr` — left to right, `writing-mode: vertical-lr`: the rare case, for a
 * Mongolian-style layout or a single vertical column dropped into a Latin page.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/vertical-text.json

Prompt

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-rl the block's height is the line length and its width is the column count, so height sizes the text while line-height sizes 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 is ceil(characters / columnChars) × lineHeight em. 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 rotationtext-orientation: mixed lays every letter and digit on its side, which is right for a word and wrong for a year. Runs of at most combineUpTo characters 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 styleline-break: strict refuses to open a column with a small kana, a long vowel mark or a closing bracket; text-spacing-trim decides 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 null and 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-rl scroll 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.

On This Page