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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ruby-text.jsonPrompt
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 foralt,titleor 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.
Prose
A typographic wrapper for CMS or Markdown HTML: token-only vertical rhythm, heading scale, lists, quotes, code, tables and figures, with size, measure and compact axes.
Drop Cap
The oversized opening letter, floated across N lines with its cap-top and baseline solved from the font's metrics — hangs an opening quotation mark and skips short paragraphs.