# Text

> SilkText draws labels and readouts from a runtime SDF glyph atlas, with outlines, glows, gradients and tabular numbers. It lives in pixi-silk/text.

Source: https://pixi-silk.schmooky.dev/docs/text/

`SilkText` from `pixi-silk/text` draws a string from distance-field glyphs, so a label under a
zooming camera keeps its edges where PixiJS `Text` turns into a stretched bitmap.

## Draw a label

Demo sources run in the docs demo runner, `defineDemo({ setup({ stage, params, tick }) })`. In your own app, `stage` is `app.stage` (or a container on it), `params` holds the values of the demo controls, and `tick(fn)` is `app.ticker.add`, with `t` and `dt` in seconds.

Live demo `sdf-text` (source):

```ts
import { Container, Graphics, Text } from 'pixi.js';
import { SilkText } from 'pixi-silk/text';
import { defineDemo, FONT, INK, label, PALETTE, readout } from './runtime';

// Left: PixiJS Text, a bitmap drawn once. Right: SilkText, distance fields drawn per pixel.
// Both zoom around the same point. The animation holds at 1x, zooms to 8x and back.
// Turn it off and drag the slider for up to 100x.
export default defineDemo({
    title: 'Text and SilkText under zoom',
    size: [720, 320],
    // phones: the two sides stack, each with its own title row
    phone: { size: [360, 440] },
    controls: {
        zoom: { type: 'range', label: 'zoom (log)', min: 0, max: 2, step: 0.01, value: 0, unit: '' },
        auto: { type: 'toggle', label: 'animate', value: true },
    },
    setup({ stage, params, tick, width, height, phone }) {
        const half = 360;
        const top = 48;
        // each side's box: its title row on top, the zooming text below
        const box = (i: number) =>
            phone ? { x: 0, y: i * 220, w: width, h: 220 } : { x: i * half, y: 0, w: half, h: height };
        const sides: Container[] = [];
        const style = { fontFamily: FONT, fontWeight: '600' as const };

        for (let i = 0; i < 2; i++) {
            const side = new Container();
            const b = box(i);
            const mask = new Graphics().rect(b.x, b.y + top, b.w, b.h - top).fill(0xffffff);
            const make = (text: string, size: number, color: number) =>
                i === 0
                    ? new Text({ text, style: { ...style, fontSize: size, fill: color } })
                    : new SilkText({ text, ...style, fontSize: size, fill: color });
            const title = make('Zoom 42%', 22, INK.text);
            const body = make('Labels, readouts and editor text', 13, INK.muted);
            const tiny = make('W 128  H 64', 11, PALETTE.blue);

            title.position.set(-100, -34);
            body.position.set(-100, 0);
            tiny.position.set(-100, 22);
            side.addChild(title, body, tiny);
            side.mask = mask;
            sides.push(side);
            stage.addChild(side, mask);
            stage.addChild(
                label(i ? 'SilkText (distance field)' : 'Text (bitmap)', b.x + 24, b.y + 16, { fontSize: 13 }),
            );
            stage.addChild(new Graphics().rect(b.x, b.y + top - 0.5, b.w, 1).fill(INK.line));
        }
        if (phone) stage.addChild(new Graphics().rect(0, 219.5, width, 1).fill(INK.line));
        else stage.addChild(new Graphics().rect(half - 0.5, 0, 1, height).fill(INK.line));
        const zoom = readout('', width - 24, 16, { fontSize: 13 }, 1);

        stage.addChild(zoom);

        // the point both sides zoom into (the "42" of the title), and the middle of the text block
        const focus = { x: -26, y: -21 };
        const block = { x: 25, y: 0 };
        const ease = (x: number) => 0.5 - 0.5 * Math.cos(Math.PI * Math.min(1, Math.max(0, x)));
        let t = 0;

        tick((_t, dt) => {
            if (params.auto) {
                t += dt;
                // a 10 s loop: 3 s at 1x, 2.5 s up to 8x, 2 s there, 2.5 s back
                const u = t % 10;
                const up = u < 3 ? 0 : u < 5.5 ? ease((u - 3) / 2.5) : u < 7.5 ? 1 : 1 - ease((u - 7.5) / 2.5);

                params.zoom = up * Math.log10(8);
            }
            const k = 10 ** params.zoom;

            // at 1x the block sits in the middle of its half, zoomed in the focus point does
            sides.forEach((side, i) => {
                const b = box(i);

                side.pivot.set(focus.x, focus.y);
                side.position.set(
                    b.x + b.w / 2 - (block.x - focus.x) / k,
                    b.y + top + (b.h - top) / 2 - (block.y - focus.y) / k,
                );
                side.scale.set(k);
            });
            zoom.text = `${k.toFixed(1)}x`;
        });
    },
});
```

```ts

const label = new SilkText({
    text: 'Frame 1',
    fontFamily: 'Inter, system-ui, sans-serif',
    fontSize: 13,
    fontWeight: 600,
    fill: 0xffffff,
    anchor: 0.5,
});

app.stage.addChild(label);
label.text = 'Frame 2'; // laid out again before the next frame
```

PixiJS `Text` draws the string once into a canvas and shows that as a texture. SilkText stores
each glyph as a signed distance field instead, and the shader turns the distance into coverage per
screen pixel. [How it works](/docs/how-it-works/) covers the idea. All texts share one atlas texture.

Change one field with its setter (`label.fontSize = 18`) or several with `label.set({ ... })`.
Width, height, bounds and hit tests use the text box, as with PixiJS `Text`.

## Add an outline, a glow or a gradient

Live demo `sdf-text-styles` (source):

```ts
import { Text } from 'pixi.js';
import { horizontal, SilkGraphics } from 'pixi-silk';
import { SilkText } from 'pixi-silk/text';
import { defineDemo, FONT, INK, label, PALETTE } from './runtime';

// Outline, glow, shadow, weight and a gradient all read the same distance field: no filters,
// still one draw call per text. Bottom left: small sizes at 1x, PixiJS Text next to SilkText.
export default defineDemo({
    title: 'SilkText styles',
    size: [720, 380],
    // phones: the styles, the small sizes and the numbers in one column
    phone: { size: [360, 640] },
    controls: {
        outline: { type: 'range', label: 'outline', min: 0, max: 6, step: 0.5, value: 3, unit: 'px' },
        glow: { type: 'range', label: 'glow', min: 0, max: 12, step: 0.5, value: 8, unit: 'px' },
        dilate: { type: 'range', label: 'weight', min: -1.5, max: 1.5, step: 0.1, value: 0, unit: 'px' },
    },
    setup({ stage, params, tick, phone }) {
        const card = phone
            ? new SilkGraphics()
                  .roundRect(16, 216, 328, 224, 20, 0.6)
                  .fill(INK.surface)
                  .roundRect(16, 456, 328, 168, 20, 0.6)
                  .fill(INK.surface)
            : new SilkGraphics()
                  .roundRect(24, 196, 440, 160, 20, 0.6)
                  .fill(INK.surface)
                  .roundRect(484, 196, 212, 160, 20, 0.6)
                  .fill(INK.surface);
        const outlined = new SilkText({
            text: 'OUTLINE',
            fontFamily: FONT,
            fontSize: 44,
            fontWeight: 900,
            fill: PALETTE.yellow,
            outline: { width: 3, color: 0x3a1c00 },
            shadow: { color: 0x000000, alpha: 0.6, blur: 6, offsetY: 4 },
        });
        const neon = new SilkText({
            text: 'Neon glow',
            fontFamily: FONT,
            fontSize: 40,
            fontWeight: 700,
            fill: 0xffe3f1,
            shadow: { color: 0xff2d92, blur: 8 },
            anchor: { x: 1, y: 0 },
        });
        const gradient = new SilkText({
            text: 'Gradient fill',
            fontFamily: FONT,
            fontSize: 34,
            fontWeight: 800,
            fill: horizontal(['#64d2ff', '#bf5af2', '#ff375f']),
        });
        const value = new SilkText({
            fontFamily: FONT,
            fontSize: 44,
            fontWeight: 600,
            fill: INK.text,
            tabularNumbers: true,
            anchor: { x: 1, y: 0 },
        });
        const unit = new SilkText({
            text: 'BPM, live',
            fontFamily: FONT,
            fontSize: 13,
            fontWeight: 600,
            fill: PALETTE.pink,
        });

        if (phone) {
            outlined.position.set(24, 20);
            neon.position.set(336, 90);
            gradient.position.set(24, 156);
            value.position.set(320, 500);
            unit.position.set(36, 584);
        } else {
            outlined.position.set(36, 28);
            neon.position.set(684, 32);
            gradient.position.set(36, 116);
            value.position.set(672, 240);
            unit.position.set(508, 316);
        }
        stage.addChild(card, outlined, neon, gradient, value, unit);
        stage.addChild(phone ? label('Tabular numbers', 36, 474) : label('Tabular numbers', 508, 214));

        // small sizes at 1x: PixiJS Text in the left column, SilkText in the right one.
        // Phones put the SilkText rows under the Text rows.
        const sample = 'Hamburgefonstiv 0123';
        const column = (i: number) => (phone ? { x: 80, y: 232 + i * 104 } : { x: [92, 280][i], y: 212 });

        stage.addChild(label('Text', column(0).x, column(0).y), label('SilkText', column(1).x, column(1).y));
        [11, 12, 13].forEach((size, i) => {
            const row = (c: number) => column(c).y + (phone ? 28 + i * 24 : 36 + i * 34);
            const pixi = new Text({ text: sample, style: { fontFamily: FONT, fontSize: size, fill: INK.text } });
            const silk = new SilkText({ text: sample, fontFamily: FONT, fontSize: size, fill: INK.text });

            pixi.position.set(column(0).x, row(0));
            silk.position.set(column(1).x, row(1));
            stage.addChild(pixi, silk);
            for (let c = 0; c < (phone ? 2 : 1); c++) {
                stage.addChild(label(`${size}px`, phone ? 32 : 44, row(c) + 1, { fontSize: 11, fill: INK.faint }));
            }
        });

        tick((t) => {
            outlined.outline = params.outline > 0 ? { width: params.outline, color: 0x3a1c00 } : null;
            neon.shadow =
                params.glow > 0 ? { color: 0xff2d92, blur: params.glow * (0.85 + 0.15 * Math.sin(t * 3)) } : null;
            for (const text of [outlined, neon, gradient]) text.dilate = params.dilate;
            value.text = (72 + 6 * Math.sin(t * 1.3) + 2 * Math.sin(t * 4.1)).toFixed(1);
        });
    },
});
```

An outline or a glow reads the same distance field at a different distance, so it needs no filter.

```ts

const title = new SilkText({
    text: 'BIG WIN',
    fontSize: 48,
    fontWeight: 900,
    fill: horizontal(['#ffd60a', '#ff9f0a']),
    outline: { width: 3, color: 0x3a1c00 },
    shadow: { color: 0x000000, alpha: 0.6, blur: 6, offsetY: 4 },
});
const readout = new SilkText({ text: '72.4', fontSize: 32, tabularNumbers: true });
```

| Option | Default | What it does |
| --- | --- | --- |
| `fontFamily`, `fontSize`, `fontWeight`, `fontStyle` | system UI font, 16, 400, normal | Any CSS font. Glyphs are baked once at a fixed size |
| `fill` | white | A colour, or a linear, radial or conic gradient over the text box |
| `letterSpacing`, `lineHeight` | 0, font | Spacing in local units. Line height 0 uses the font's own |
| `align`, `wordWrap`, `wordWrapWidth`, `breakWords` | left, off, 100, off | Line layout, as in PixiJS `Text` |
| `anchor` | 0 | Point of the text box at the position |
| `outline` | none | `{ width, color, alpha }`, outside the glyph edge |
| `shadow` | none | `{ color, alpha, blur, offsetX, offsetY }`. Without an offset it is a glow |
| `dilate` | 0 | Grows or thins the glyphs, in local units |
| `tabularNumbers` | off | Digits share one width, so a changing number does not jitter |
| `kerning` | on | Pair kerning measured from Canvas |

Outlines and glows reach about a quarter of the font size. Past that the distance field runs out
and the effect is clamped. For a wider halo, put a blurred SilkGraphics shape behind the text.

## Small sizes

At 11 to 13 px a texel of the atlas is smaller than a screen pixel. SilkText then averages four
samples per pixel, so thin stems do not flicker. The bottom left of the first demo shows both
kinds of text at 11, 12 and 13 px.

Glyph shapes and widths match PixiJS `Text`, because both come from the same Canvas font and
`measureText`. Some platforms hint Canvas text to the pixel grid, so at 1x and no zoom its stems
can look a little crisper. From 2x, or under a camera, SilkText wins.

## Use SilkText inside widgets

Widgets that show a value take a `label` option, a function that builds their text. Pass
`silkLabel()` for widgets that sit under a zooming camera. The widget never imports SilkText
itself, so apps that do not use it do not ship it.

```ts

const pill = new ValuePill({ label: silkLabel({ tabularNumbers: true }) });

pill.show('42 px');
```

To let people click and type into a text, wrap it in a `TextEditor`. See [Editing text](/docs/editing-text/).

## Fonts and the atlas

Each glyph is drawn with Canvas2D at 48 px the first time it appears, then turned into a distance
field. The atlas is one 8-bit texture, 1024 texels wide, that doubles in height up to 4096. When it
is full it starts over, and every text bakes what it needs again.

Load web fonts before the first text uses them, for example with `document.fonts.load('600 16px Inter')`.
Glyphs baked while a font was still loading are baked again once it is ready.

For a different trade-off, make your own atlas and pass it to the texts that should use it:

```ts

const big = new SdfAtlas({ size: 64, radius: 24 }); // sharper corners far in, longer glows
const heading = new SilkText({ text: 'Overview', fontSize: 40, atlas: big });
```

## Where it stops

Corners round off slightly at extreme zoom, because a single-channel distance field cannot hold a
sharp corner. At the default 48 px bake the rounding is about a fiftieth of the font size.

Emoji and colour fonts come out as one-colour silhouettes. There is no shaping: no ligatures, no
right-to-left text and no combining marks, and each code point is one glyph. Latin, Greek and
Cyrillic UI text works. Use PixiJS `Text` for emoji, Arabic and Indic scripts.

SilkText runs on WebGL2 and WebGPU. Pixi's WebGL1 fallback is not supported.
