Text

Ruby Text

Ruby annotations for CJK and phonetic glosses — parsed from an inline notation or paired arrays, with a parenthesised fallback, per-annotation alignment and leading reserved up front so hiding the readings never reflows the paragraph.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * `center` keeps the base characters at their natural spacing.
 * `space-around` / `space-between` spread a short base under a long annotation
 * (the traditional Japanese look; `space-around` is the CSS initial value).
 * `start` pins the annotation to the start edge of the base.
 */
export type RubyAlign = "center" | "start" | "space-around" | "space-between"

/** Annotation side. `under` is the usual place for pinyin and for glosses. */
export type RubyPosition = "over" | "under"

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "RubyText" component: real <ruby> markup
for furigana, pinyin and phonetic glosses. No dependencies beyond React and a
cn() class merger — no parser library, no measurement, no effects.

Contract
- Pure and synchronous: no state, no refs of its own, no browser API, so the
  file needs no "use client" and its first server-rendered frame is the final
  one. forwardRef<HTMLSpanElement> onto the root <span>; every native prop that
  is not listed below is spread there.
- Props extend Omit<React.HTMLAttributes<HTMLSpanElement>, "children">:
  text?: string — source in the inline notation, `漢字(かんじ)を学(まな)ぶ`;
  segments?: readonly RubySegment[] — pre-paired input, wins over text;
  ratio?: number (0.5) — gloss size in em, clamped to 0.2–1;
  align?: "center" | "start" | "space-around" | "space-between" ("center");
  position?: "over" | "under" ("over");
  annotations?: boolean (true) — show the gloss layer;
  readAs?: "base" | "annotation" | "both" ("base");
  lineHeight?: number | "inherit" (1.5) — a floor, or opt out entirely;
  delimiters?: readonly [open, close][] ([["(", ")"], ["(", ")"]]);
  annotationClassName?: string — extra classes on every <rt>.
- RubySegment = { base: string; ruby?: string; align?: RubyAlign }. A segment
  without `ruby` is plain text, so one array describes a whole sentence.
- Export two pure helpers next to the component:
  parseRubyText(source, delimiters?) => RubySegment[], and
  rubyPlainText(segments, { annotations?, delimiters? }) => string, which
  reproduces exactly what the <rp> fallback prints — use it for alt text, a
  title, a plain-text export or a clipboard payload.

Behavior — parsing
- The base of an annotation is either explicit — `{お手洗い}(おてあらい)`, a
  brace group immediately before the marker — or implicit: the run of
  characters right before the marker that share its script class. Four classes:
  Han (including the iteration marks 々 〇 and the astral extensions, matched by
  code point, so `時々(ときどき)` and `𠮟(しか)る` both work), kana, Hangul, and
  "letters or digits" for everything else. The class of the single character in
  front of the marker decides; the run walks left while the class holds. That is what makes `に行(い)く` annotate 行 alone instead of swallowing
  the preceding kana, and what makes `schedule(ˈskɛdʒuːl)` take the whole word.
- A backslash escapes a marker, a brace or another backslash; a backslash
  before anything else is a literal backslash, so `C:\Users` survives intact.
  The same rule applies while scanning for the closing marker, so an escaped
  `\)` does not end the annotation.
- The parser never deletes what it did not understand. An empty annotation
  `漢字()`, one that never closes `漢字(かんじ`, or one with no base in front of
  it `(かんじ)` is copied through verbatim, markers and all — the author sees
  the typo instead of losing characters. Same for a brace group that no
  annotation follows.
- Both ASCII and full-width markers parse by default, because a CJK keyboard
  produces `()`; the first pair in the list is the one <rp> prints back.
- Whitespace inside an annotation is trimmed at the ends only, so pinyin like
  `(hànyǔ pīnyīn)` keeps its internal space.

Behavior — layout and state
- Reserve the leading up front: line-height = max(lineHeight prop,
  1 + 2 × (ratio + 0.05)). The annotation box is `ratio` em tall and lives in
  the half-leading, which is (L − 1) / 2 em, so this is the smallest leading
  that fits it. Consequence: an annotated line and a bare line are exactly as
  far apart, and adding a reading later cannot re-break the paragraph.
  lineHeight="inherit" writes no line-height at all and hands the reservation
  back to the consumer's CSS.
- annotations={false} hides the layer with `visibility: hidden` on the <rt> and
  on both <rp> — it never unmounts them. This is the whole trick: an unmounted
  <rt> gives the base its narrow width back and shrinks the line, so a naive
  toggle reflows in both axes. A hidden box still reserves the width a long
  reading forced onto a short base.
- ratio is applied as an inline font-size in em on the <rt>, together with
  line-height: 1 — otherwise the reserved leading inherits into the annotation
  and doubles its box.
- position writes ruby-position, plus -webkit-ruby-position with Safari's older
  spelling (before / after). align writes ruby-align per <ruby>, with the
  segment's own align overriding the prop; where an engine has not implemented
  ruby-align the annotation simply centres, which is the sane failure.

ARIA
- Screen readers disagree about ruby: some read base and gloss interleaved,
  which garbles the sentence. readAs settles it explicitly. "base" (default)
  puts aria-hidden on the <rt>: the gloss is a sighted reading aid.
  "annotation" wraps the base in an aria-hidden span instead, for a
  transliteration that is meant to be spoken. "both" hides nothing and leaves
  native behaviour alone.
- When annotations are off, readAs is forced back to "base": a hidden <rt> is
  already out of the accessibility tree, so keeping "annotation" would leave the
  segment with nothing readable at all.
- Plain segments are never hidden from assistive tech — only an annotated base
  can be, and only when its gloss is taking over.
- Keyboard map: none, deliberately. Nothing here is focusable and nothing holds
  state; the show/hide switch is the consumer's button driving the annotations
  prop, so it can live in a toolbar, a settings menu or a URL param. Cleanup:
  none either — no timers, listeners or observers exist to leak.
- Deliberately out of scope: ruby-position: inter-character (bopomofo), whose
  reservation is horizontal rather than vertical; and the clipboard, where
  Chromium copies base and gloss as one run — if that matters, add an onCopy
  handler that rewrites the payload with rubyPlainText(segments).

Rendering & styling
- Semantic tokens only: the <rt> is text-muted-foreground, everything else
  inherits from the surrounding copy. No colour, radius or shadow is hardcoded,
  so the component is already correct in dark mode.
- cn() merges className into the root and annotationClassName into every <rt>,
  in that order, so a consumer's text-primary wins over the muted default while
  the invisible state that follows it cannot be overridden by accident.
- data-slot="ruby-text" | "ruby" | "ruby-annotation" and
  data-annotations="visible" | "hidden" on the root, so a stylesheet can reach
  the layer without prop drilling.
- Motion: there is none, by design — a reading that fades in is a reading you
  cannot trust to be there, so nothing animates and prefers-reduced-motion has
  nothing to switch off.

Customization levers
- Density: ratio is the single dial; the leading follows it automatically.
  Raise it to 0.6 for a children's reader, drop it to 0.35 for dense body copy,
  and pass a bigger lineHeight when you want air beyond the minimum.
- Sides and alignment: position="under" for pinyin or interlinear glosses,
  align="space-between" for the traditional spread of a short base under a long
  reading, or set align per segment when one term in the sentence needs it.
- Colour and weight: annotationClassName is the hook — text-primary for a
  learning mode, opacity-70 for a quieter gloss, text-[0.9em] tracking-tight if
  a specific font needs it. Recolour the base from the outside as usual.
- Notation: pass your own delimiters to accept `漢字《かんじ》` or a bracket pair
  your CMS already uses; the fallback then prints that pair too.
- Input shape: keep text for authored copy, switch to segments when the pairs
  come from a dictionary API or a morphological analyser — the render path is
  identical, the parser is simply skipped.
- Toggle: drive annotations from a button, a user setting or a media query;
  because hiding is a visibility change, you can even animate the surrounding
  layout without fear of a reflow.

Concepts

  • Reserved leading — the line height is computed from the annotation size, not from whether this line happens to carry one, so annotated and bare lines sit at the same distance and a reading added later never re-breaks the paragraph.
  • Hide by visibility, not by unmounting — the <rt> stays in the box tree when the layer is off, which keeps both the line height and the width a long reading forced onto a short base; the naive conditional render shifts the text in both axes.
  • Script-run base detection — the character in front of the marker picks a class (Han, kana, Hangul, or letters and digits) and the base is the run of that class, which is why に行(い)く annotates one kanji instead of eating the kana before it; braces are the escape hatch for a mixed-script base.
  • Verbatim refusal — an annotation that never closes, has no content, or has no base is not repaired and not deleted: the characters are printed exactly as typed, so a notation typo is visible instead of silently swallowed.
  • Fallback as text<rp> parentheses ride along with every annotation, so an engine without ruby support prints 漢字(かんじ) inline, and the same string is available to your code for alt, title or the clipboard.
  • Read-as contract — screen readers interleave ruby differently, so the component picks a side on purpose: the sentence by default, the gloss when the gloss is the pronunciation being taught, both only when you ask for it.

On This Page