Text
Control layout, styling, and visual effects for runtime text.
Text
Text renders GPU-accelerated text strings as nodes in the scene graph. Each Text instance rasterizes its glyphs into a shared glyph atlas and hands the resulting quads to the text renderer, which merges every quad that shares a shader type and an atlas page into one draw call — across text nodes, not just within a single one.
Text is a Drawable (via AbstractText), not a Container: it carries the full transform, tint, blend mode, pixel-snap, filter, and mask surface of any other drawable, but it holds no children of its own. Nest it inside a Container when you need to group it with other nodes.
A minimal text node
import { Color, Text } from '@codexo/exojs';
const label = new Text('Hello', {
fillColor: Color.white,
fontSize: 24,
fontFamily: 'Arial',
});
The second argument is a TextOptions object — a flat merge of TextStyleOptions (visual appearance) and LayoutOptions (flow and overflow). All properties are optional and have defaults. The live TextStyle is stored as text.style; mutating its fields is cheap and is applied automatically before the next render pass — no manual rebuild call is needed:
import { Color, Text } from '@codexo/exojs';
const label = new Text('Hello', { fillColor: Color.black, fontSize: 24 });
label.style.fontSize = 32; // re-lays out the glyphs on the next draw
label.style.fillColor = new Color(0xff6347); // shader-side colour only — no atlas work
Font loading
System fonts (Arial, Times New Roman, etc.) work immediately. For custom web fonts, load one via Asset.type('font', ...) in the scene’s load hook — fonts aren’t a seamless type, so a bare path isn’t enough; an explicit descriptor is required even for a literal path, and family is a required option:
override async load(): Promise<void> {
await this.loader.load(Asset.type('font', 'font/MyFont.woff2', { family: 'MyFont' }));
}
override init(): void {
this.title = new Text('Custom Font', {
fontFamily: 'MyFont',
fontSize: 48,
fillColor: Color.white,
});
}Loading resolves to a FontFace, registered with the document’s document.fonts set. Once loaded, it becomes available to all Text instances via the fontFamily style property.
You can also hand the loaded face to the node directly through the font style option. Text registers it with document.fonts itself and rebuilds once it is ready, which removes the ordering requirement between loading and construction:
override async load(): Promise<void> {
const face = await this.loader.load(Asset.type('font', 'font/MyFont.woff2', { family: 'MyFont' }));
this.title = new Text('Custom Font', { font: face, fontSize: 48 });
}font takes precedence over fontFamily when both are set.
Style properties
The visual appearance comes from TextStyleOptions. All properties are optional:
| Property | Type | Default | Description |
|---|---|---|---|
font |
FontFace |
— | Pre-loaded font face; takes precedence over fontFamily |
fontFamily |
string |
'Arial' |
CSS font family name |
fontWeight |
FontWeight |
'normal' |
CSS font weight ('normal', 'bold', or '100'–'900') |
fontStyle |
'normal' | 'italic' | 'oblique' |
'normal' |
Font style. 'oblique' asks for a fixed slant rather than the family’s own italic |
fontVariant |
'normal' | 'small-caps' |
'normal' |
Caps variant. Small caps come from the family’s own glyphs where it has them, otherwise from the browser’s synthesis |
fontSize |
number |
20 |
Font size in pixels |
fillColor |
Color |
Color.white |
Glyph fill colour |
outlineColor |
Color |
Color.black |
SDF outline colour |
outlineWidth |
number |
0 |
Outline width in SDF units (0–0.5); 0 disables the outline |
align |
TextAlignment — 'left' | 'center' | 'right' | 'justify' |
'left' |
Horizontal alignment |
textTransform |
'none' | 'uppercase' | 'lowercase' | 'capitalize' |
'none' |
Case mapping applied before layout; the node’s text is untouched |
lineHeight |
number |
1.2 |
Line-height multiplier on fontSize |
leading |
number |
0 |
Extra pixel gap between lines |
shadowColor |
Color |
Color.black |
Drop-shadow colour |
shadowOffsetX |
number |
0 |
Horizontal shadow offset in pixels |
shadowOffsetY |
number |
0 |
Vertical shadow offset in pixels |
shadowAlpha |
number |
0 |
Shadow opacity (0–1); 0 disables the shadow |
shadowBlur |
number |
0 |
Shadow blur softness (0–1) |
underline |
boolean |
false |
Draw a rule under each line |
strikethrough |
boolean |
false |
Draw a rule through each line |
decorationColor |
Color | null |
null |
Rule colour; null follows the fill, gradient included |
decorationThickness |
number |
0 |
Rule thickness in pixels; 0 derives it from the font size |
decorationOffset |
number |
0 |
Extra downward offset applied to both rules |
gradient |
TextGradient | null |
null |
Multi-stop fill gradient; overrides fillColor |
A gradient takes up to eight colour stops and an angle in degrees. The angle follows the CSS linear-gradient convention — 0 runs towards the top, 90 towards the right, increasing clockwise — and defaults to 180, top to bottom. The ramp spans the ink extent (getLocalBounds()) corner to corner along that direction, not the advance box, so the first and last stops always land on the box edges:
import { Color, Text } from '@codexo/exojs';
const banner = new Text('Level Up', {
fontSize: 48,
gradient: {
stops: [
{ offset: 0, color: Color.white },
{ offset: 0.6, color: new Color(0xffd700) },
{ offset: 1, color: new Color(0xff6347) },
],
angle: 200,
},
});
Stop offsets are clamped to 0–1 and sorted on the way in, and the colours are cloned, so the object you pass is never aliased. Reading style.gradient back gives the normalized form. The ramp is evaluated in the fragment shader from the node’s own packed style, so changing it costs no atlas work — it is a 'tint' change like fillColor.
Underline and strikethrough
Both rules are quads the layout emits per line, so they follow the alignment, the wrap and the letter spacing without any further work:
import { Color, Text } from '@codexo/exojs';
const link = new Text('Read the manual', { fontSize: 18, underline: true });
const removed = new Text('Old price', { fontSize: 18, strikethrough: true, decorationColor: Color.red });
Their position and thickness come from the font’s own metrics rather than from a fraction of the font size: the underline sits inside the descender space and the strikethrough is centred on half the x-height, which is what keeps a rule in the same relation to the letters across typefaces. decorationThickness and decorationOffset override the two numbers when a design needs a specific one.
A rule samples an opaque block in the glyph atlas, so by default it takes exactly the fill the glyphs take — including a gradient. decorationColor replaces that for the rules only.
A BitmapText draws from an offline atlas that has no opaque block reserved in it, so it renders no rules.
Style and caps variants
fontStyle and fontVariant go into the CSS font shorthand the glyph rasterizer hands to Canvas 2D, so what you get is whatever the browser gets: a family’s real italic or small-cap glyphs where it ships them, and the browser’s synthesis where it does not.
import { Text } from '@codexo/exojs';
const chapter = new Text('Chapter One', { fontSize: 28, fontVariant: 'small-caps' });
const aside = new Text('a passing thought', { fontSize: 16, fontStyle: 'oblique' });
Both are part of a glyph atlas’s identity, so a small-cap a and an ordinary a never share a cache entry, and 'oblique' gets its own atlas rather than borrowing the italic one. That costs a second set of pages for a node that mixes variants — worth knowing before styling every label differently.
A BitmapText draws from an atlas somebody else baked, which contains exactly the glyphs it contains. Neither property has anything to act on there.
Text flow and overflow come from LayoutOptions, merged into the same options object:
| Property | Type | Default | Description |
|---|---|---|---|
maxWidth |
number |
— | Word-wrap boundary in pixels; longer lines break at word boundaries |
maxHeight |
number |
— | Vertical boundary in pixels; only whole lines that fit are kept. No effect on its own — pair it with overflow |
maxLines |
number |
— | Hard cap on the laid-out line count, applied after wrapping. Clips on its own; pair with overflow: 'ellipsis' for a marker |
overflow |
'visible' | 'clip' | 'ellipsis' |
'visible' |
What happens to lines that do not fit maxHeight or maxLines: keep them all, drop them, or drop them and mark the last visible line |
ellipsis |
string |
'…' |
Marker appended under overflow: 'ellipsis'. '...' for the three-period spelling, '' to truncate silently |
letterSpacing |
number |
0 |
Extra pixel gap between glyphs |
tabSize |
number |
8 |
Tab stop spacing in space widths, matching the CSS tab-size initial value. Only preserved tabs reach it — see whiteSpace |
direction |
'ltr' | 'rtl' |
'ltr' |
'rtl' reverses each line’s glyphs after wrapping; no full bidi or Arabic shaping, and align stays literal |
breakWords |
boolean |
false |
Break words wider than maxWidth at character boundaries |
whiteSpace |
'normal' | 'pre' | 'pre-line' |
'pre-line' |
Whitespace handling |
Under whiteSpace: 'pre' a tab advances the pen to the next tabSize stop measured from the start of its line, so a run of tab-separated values lines up as columns instead of drifting. The collapsing modes turn a tab into a single space before layout runs, exactly as CSS does, so tabSize has nothing to act on there. A browser-shaped line is laid out whole by the platform’s text engine and ignores the setting.
Layout options stay reachable as text.layout. Unlike style, this is a plain options object with no change tracking, and the node holds its own copy of it: assign a new one to re-flow the text — mutating the object you passed in, or the one the getter hands back, changes nothing.
Glyphs are rasterized white into the shared SDF atlas and coloured at draw time, so fillColor, outlineColor, the shadow, and the gradient are shader work only — changing them never touches the atlas.
Case without rewriting the string
textTransform maps case at layout time, exactly as CSS text-transform does. The node’s text is untouched, so a label reads back the string you assigned and an editable widget keeps a caret that lands where the reader clicked:
import { Text } from '@codexo/exojs';
const heading = new Text('level complete', { fontSize: 32, textTransform: 'uppercase' });
heading.text; // 'level complete' — the mapping lives in the layout, not in the string
The mapping runs per grapheme cluster and is Unicode-aware, so a cluster that changes length under it — German ß uppercasing to SS — still traces back to the character it came from. 'capitalize' raises the first cluster of each word and leaves the rest of the word alone, and word boundaries come from the platform segmenter rather than from splitting on spaces.
Set locale and both the case mapping and the word boundaries follow it, which is the difference between a Turkish I lowercasing to ı and to i.
Multiline text and wrap settings
Multiline text works by embedding \n characters in the string:
import { Color, Text } from '@codexo/exojs';
const dialog = new Text('Line one\nLine two\nLine three', {
fillColor: Color.white,
fontSize: 18,
lineHeight: 1.5,
});
To wrap long runs automatically, set maxWidth (in pixels) — lines that exceed it break at word boundaries. Add breakWords: true to also split individual words that are wider than maxWidth:
import { Color, Text } from '@codexo/exojs';
const longString = 'A long run of text that wraps automatically once it exceeds the layout width.';
const wrapped = new Text(longString, {
fillColor: Color.white,
fontSize: 16,
maxWidth: 400,
breakWords: true,
});
Clamping to a line count
maxLines caps how many lines survive, counted after wrapping. It clips on its own, so it needs no overflow policy; add overflow: 'ellipsis' when the truncation should be visible:
import { Text } from '@codexo/exojs';
const body = 'A long article body that runs well past three lines of the layout width.';
const summary = new Text(body, {
fontSize: 16,
maxWidth: 320,
maxLines: 3,
overflow: 'ellipsis',
});
The marker defaults to '…' and ellipsis replaces it — '...' for the three-period spelling, '' to truncate with no marker at all. Whatever it is, it is measured in the same font as the text, and the line gives up whole grapheme clusters until the two fit maxWidth together.
Under a line cap the marker also reaches a line that no wrap could shorten. maxLines: 1 on a single unbreakable word drops no line at all, yet the word still overflows maxWidth — and that is the case a one-line label wanted the marker for:
const label = new Text('Supercalifragilistic', {
maxWidth: 120,
maxLines: 1,
overflow: 'ellipsis',
});
maxHeight expresses the same clamp in pixels and still requires an overflow policy. Set both and the tighter one wins.
International text
Four separate concerns decide how a string ends up on screen. They are easy to confuse, and only the first two are about typography:
- Segmentation — where the text may safely be split.
- Shaping — whether a glyph’s appearance depends on the glyphs around it.
- Raster density — how much glyph information is stored (
pixelRatio). - Reconstruction — how that information is turned back into an edge on screen.
Segmentation: the unit is a cluster, not a character
Layout counts grapheme clusters, not code points. A combining sequence (e + acute), an emoji with a skin-tone modifier, a ZWJ sequence like 👨👩👧 and a regional-indicator flag pair are each one cluster made of several code points, and each is placed as one glyph. Wrapping, breakWords and the 'ellipsis' overflow all cut between clusters, so truncation never leaves a dangling mark or half a flag behind.
Word boundaries come from the platform’s own segmentation rather than from splitting on spaces, so a language written without inter-word spaces wraps at its own boundaries instead of overflowing as one token:
import { Text } from '@codexo/exojs';
const jp = new Text('日本語のテキストは自動的に折り返されます', {
fontSize: 18,
maxWidth: 200,
locale: 'ja',
});
locale selects the segmentation locale. It picks no font and loads nothing; leave it out and the platform default applies.
Shaping: when a glyph needs its neighbours
Most text renders one glyph at a time out of a shared atlas — the same S serves every label on screen, which is what makes thousands of them cheap. Some scripts cannot work that way. An Arabic letter changes shape depending on what it joins to, and a line mixing Latin and Hebrew has a visual order that follows from the whole line, not from any character in it.
For those, ExoJS hands the complete laid-out line to the browser’s own text engine, which resolves the bidirectional order and the contextual forms, and renders the result. shaping selects between the two:
import { Color, Text } from '@codexo/exojs';
// 'auto' (the default): plain Latin takes the shared-glyph path.
const score = new Text('Score: 1234', { fillColor: Color.white, fontSize: 20 });
// 'auto' again: Arabic needs its neighbours, so the browser shapes the line.
const heading = new Text('الإصدار 12', { fillColor: Color.white, fontSize: 20 });
// Forced, for controlled content and for benchmarks.
const forced = new Text('Score: 1234', { fillColor: Color.white, fontSize: 20, shaping: 'simple' });
text.shapingMode reports which one settled, 'simple' or 'browser'.
direction sets the base direction a mixed line is resolved against. It is explicit — the engine does not guess it from the text or from the locale, because neither implies the other:
import { Text } from '@codexo/exojs';
const rtl = new Text('Build 42 — الإصدار 12 (Beta)', { fontSize: 20, direction: 'rtl' });
What this costs, and what it does not
The shared-glyph path is unchanged. A HUD counter going from 1234 to 1235 still reuses every glyph it already has and rasterizes only what is new, and static text does no segmentation, no measurement and no rasterization on a frame where nothing changed.
Browser-shaped text has a different cost model on purpose. Its unit is a line, so a line whose text changes is rasterized again, and the raster belongs to the node rather than to the shared atlas — which is why it is worth forcing shaping: 'simple' for content you know is simple, and worth not putting contextual text in a per-frame readout.
A browser-shaped line is one glyph as far as layout is concerned. align: 'justify' therefore cannot stretch it, letterSpacing is applied inside the shaping rather than between placements, and caret geometry resolves to line granularity. The editing widgets (TextInput, TextArea) pin shaping: 'simple' for that reason.
Limits worth knowing
- Segmentation uses the platform’s
Intl.Segmenter. Where a browser does not provide it, clusters degrade to code points: surrogate pairs still survive, but a combining sequence, a ZWJ sequence or a flag may be split by wrapping or truncation, and word boundaries fall back to blank runs. No polyfill and no Unicode tables ship with the engine. - ExoJS does not implement the Unicode bidirectional algorithm itself. It supplies the base direction and lets the browser resolve the line, which is enough to render mixed text correctly but not to own a logical-to-visual mapping — so caret navigation, selection and hit testing inside contextual text are not supported yet.
- Word segmentation is not full Unicode line breaking. Hyphenation points and the finer break rules of some scripts are not modelled.
- Font fallback for a character the chosen family lacks is the browser’s, not the engine’s. There is no engine-owned fallback ordering.
BitmapTextis unaffected: a prebuilt font holds pre-generated glyphs and usually no contextual data, so it stays on the per-glyph path.
Alignment
The align property controls horizontal positioning relative to the Text node’s local origin:
import { Color, Text } from '@codexo/exojs';
const centered = new Text('Centered Title', {
align: 'center',
fillColor: Color.white,
fontSize: 32,
});
centered.setPosition(400, 20);
A center-aligned text node at (400, 20) centers its glyphs horizontally around x=400 in local space.
'justify' is the fourth mode: it spreads the extra space of a line evenly across its inter-word gaps so every line reaches the width of the widest line. The last line and any line with a single word are left alone, so a justified block never stretches a trailing fragment.
Text in the scene graph
As a Drawable, Text carries position, rotation, scale, origin, and anchor like any other scene node. You can rotate text, tint the whole node, apply filters, and add it to a container — but not add children to it:
this.hud = new Container();
this.scoreLabel = new Text('Score: 0', { fillColor: Color.white, fontSize: 24 });
this.scoreLabel.setPosition(10, 10);
this.hud.addChild(this.scoreLabel);
this.addChild(this.hud);The glyph geometry is not a child node — it lives on the Text itself as per-atlas-page quad data the text renderer consumes. Nothing you assign lays the text out on the spot: assigning text.text, text.layout or a whole new text.style, and mutating style fields (e.g. text.style.fillColor = ...), all just mark the geometry stale. Any number of changes in one frame coalesce into at most one layout pass, which runs the moment something reads the node — its bounds, its measurements, or the renderer’s collect phase, whichever comes first. Assigning the same string is a no-op, so changing the text every frame only costs a pass on the frames it actually changes. text.syncDirty() forces the pass early, but you rarely need it: every read resolves on its own.
The node’s local bounds track the laid-out text, so an anchored label re-derives its origin whenever the string changes width — a centred score readout stays centred as it grows.
Measuring text
A laid-out string has two honest sizes, and picking the wrong one is the usual source of a panel that fits badly:
text.textBoundsis the advance — where the cursor ends up. This is the layout measure: how much room the string takes up in the flow, and where a caret or a following element belongs. It is aTextSize({ width, height }).text.getLocalBounds()is the ink — the rectangle the glyph quads actually cover. It includes the SDF padding, and therefore the reach of any outline or shadow. In SDF mode it starts at a slightly negativex/yand is a little larger than the advance on both axes. This is what culling, hit-testing and the gradient ramp use.
Use the advance to size a panel around a label or to position something next to it:
this.label = new Text('Ready', { fontSize: 24 });
this.backdrop = new Panel({
width: this.label.textBounds.width + 16,
height: this.label.textBounds.height + 8,
});Both reads resolve a pending layout pass first, so the number you get always reflects the changes you have made — including a style mutation on the line above.
To measure without a node at all — sizing a button before you have anything to put in it, deciding whether a caption fits — use the static counterpart. It takes the same options as the constructor and runs the same layout pass over the same shared font metrics, so it answers exactly what the node’s textBounds would:
import { BitmapText, Text, type BmFont } from '@codexo/exojs';
declare const font: BmFont;
const { width } = Text.measure('Continue', { fontSize: 24 });
const { height } = BitmapText.measure('Continue', font, { scale: 2 });
Text.measure costs one canvas measurement per glyph it has not seen before and nothing else: it creates no atlas, rasterizes no glyph and claims no atlas space. Its answer is therefore also independent of pixelRatio — see below. BitmapText.measure reads a pre-built atlas and costs nothing beyond the layout.
HiDPI: glyph raster density
Glyphs are rasterized onto a real pixel grid, so on a high-density display they need more of them. A text node therefore rasterizes at the pixelRatio of the Application that draws it — set canvas.pixelRatio: 2 and its glyphs are rendered from a 2x font onto a 2x atlas tile. You do not have to do anything for that; it is the default, and it is deterministic: nothing in the text stack consults window.devicePixelRatio, so an application pinned at 2 renders text at 2 on a device that reports 3.
Text.pixelRatio decouples one node’s glyph raster from the surface it is drawn on:
import { Application, Text } from '@codexo/exojs';
const app = new Application({ canvas: { pixelRatio: 2 } });
// Inherits the application's ratio: rasterized at 2.
const label = new Text('Ammo 24/60', { fontSize: 9 });
// Overridden: rasterized at 3 while the surface stays at 2.
const crisp = new Text('Ammo 24/60', { fontSize: 9, pixelRatio: 3 });
Both nodes lay out identically. The logical font size, the advances, kerning, wrapping, line breaks, alignment, the reach of an outline or shadow, and everything textBounds and Text.measure report are the same at every ratio — only sharpness, atlas tile size and memory change. text.pixelRatio reports the override and is undefined when there is none; assign undefined to go back to inheriting. text.rasterPixelRatio reports the density actually in force.
The value to want is usually the inherited one. Lowering it lowers the raster resolution the distance field is built from, so the quality floor it eventually hits depends on the glyph — its size, the thinnest stroke in the typeface, the SDF radius, an outline — rather than on the ratio alone. Small text reaches that floor first: on an iPhone 13 Pro, 9 and 11px rows pinned to ratio 1 lost their stems while the 16px row held.
Raising it costs roughly the square of the ratio. Measured over an ASCII set at 9, 11 and 16px: 146k atlas texels at ratio 1, 582k at ratio 2 and 1.29M at ratio 3, enough to push that set from one 1024x1024 page to two. For unscaled screen text at those sizes, the same device showed no visible gain from going above the inherited ratio — which is what makes inherit the right default rather than a compromise.
The case for raising it is content whose on-screen density exceeds the surface ratio: a label scaled up at runtime, or one drawn through a zoomed camera, samples its atlas over more device pixels than the surface ratio suggests. The case for lowering it is trading sharpness for atlas memory — safe on large text, harmful on small.
Antialiasing is not part of this decision. The shader derives its edge width from how many device pixels the node covers on screen, so a glyph fades over about one device pixel however its atlas density, the surface ratio, its own scale and the camera’s zoom combined to put it there. The three concerns stay separate: pixelRatio decides how much glyph information is stored, the atlas sampler decides how it is reconstructed between texels, and the shader decides how wide the reconstructed edge lands on the screen.
Text constraints
- One glyph atlas is shared per font variant (family, style, weight, colour mode, SDF radius, raster pixel ratio), so nodes that differ only in size or colour reuse the same atlas — and nodes that differ in family, weight or pixel ratio do not. The atlas grows by adding 1024×1024 pages as needed; large sets of unique glyph/size combinations cost VRAM rather than failing, but a single glyph larger than one page throws.
- Browser-shaped lines are owned by the node that laid them out rather than pooled, so identical contextual strings in different nodes each hold their own raster. There is no shared shaped-line cache.
Textdoes not expose per-character styling. Use separateTextinstances for mixed-style strings.Textis a leaf drawable, not a container — it takes no children.- Loader-based
FontAssetloading is the built-in path for custom fonts; any font family available to the browser’s canvas text engine can be used once loaded.
Examples
A text node with a custom web font, updating its string each frame.
Multiline text with alignment and line-height control.
The style sweep in one scene: an angled multi-stop gradient, small caps and oblique, underline and strikethrough, textTransform, a clamped paragraph with an ellipsis, and tab-aligned columns.
Where to go next
The next chapter, Animation, covers frame-based sprite animation, tweens for interpolated motion, and how to combine manual frame-loop updates with automated tween-driven timing.
