---
title: "Rich text — Svelte"
description: "Text boxes can mix styles, like a bold word or a red sentence. How they're stored, how your toolbar formats them, and how to show your own fonts."
framework: "Svelte"
source: "https://www.cloudpdf.com/docs/headless/svelte/concepts/rich-text"
---

# Rich text

A text box (a free text annotation) can mix styles: a bold word, a red sentence, a bigger heading.
Double-click the box below, select a word, and use the buttons, or press `Ctrl`/`Cmd` with `B`, `I`
or `U` while you type:

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { SelectionLayer, selectionPlugin } from '@embedpdf/svelte/selection';
  import { interactionPlugin } from '@embedpdf/svelte/interaction';
  import { AnnotationLayer, annotationPlugin } from '@embedpdf/svelte/annotation';
  import { cloudEngine } from '@cloudpdf/engine';
  import SelectRichText from './SelectRichText.svelte';
  import RichTextToolbar from './RichTextToolbar.svelte';

  import '../rich-text.css';

  const engine = cloudEngine();
  const plugins = [
    stagePlugin(),
    renderPlugin(),
    interactionPlugin(),
    selectionPlugin(),
    annotationPlugin(),
  ];

  const notice: OpenInput = { kind: 'grant', grant: 'shr_LfeeUer1ogBI9mz8eYkWXa0d' };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: notice }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <SelectRichText />
    <RichTextToolbar />
    <Stage class="stage">
      <RenderLayer />
      <SelectionLayer />
      <AnnotationLayer />
    </Stage>
  </DocumentGate>
</Viewer>
```

**`rich-text.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --text: #3b4b7a;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The controls, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 12px;
  cursor: pointer;
  white-space: nowrap;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:active {
  transform: translateY(1px);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

.hint {
  margin: 0;
  font-size: 13px;
  color: var(--muted);
}

/* Each format button shows its own format */
.bold {
  font-weight: 800;
}

.italic {
  font-style: italic;
}

.underline {
  text-decoration: underline;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

**`RichTextToolbar.svelte`**

```svelte
<!-- The same calls style the selected words while typing, and the whole box otherwise. -->
<script lang="ts">
  import { useAnnotation, useAnnotationProperties } from '@embedpdf/svelte/annotation';

  type Format = 'bold' | 'italic' | 'underline';
  const FORMATS: readonly Format[] = ['bold', 'italic', 'underline'];

  const annotation = useAnnotation();
  const panel = useAnnotationProperties();
  const hasText = $derived(
    panel.current.properties.some((property) => property.control === 'textFormat'),
  );
  const large = $derived(panel.current.values.fontSize === 24);
  // On when every selected word has it; `mixed` lists what the words disagree on.
  const isOn = (format: Format) =>
    panel.current.values[format] === true && !panel.current.mixed.includes(format);
</script>

<div class="toolbar">
  {#each FORMATS as format (format)}
    <button
      type="button"
      class="button {format}"
      aria-pressed={isOn(format)}
      disabled={!hasText}
      onclick={() => annotation.text.toggleFormat(format)}
    >
      {format[0]!.toUpperCase()}
    </button>
  {/each}
  <button
    type="button"
    class="button"
    disabled={!hasText}
    onclick={() => annotation.selection.update({ fontColor: '#c00000' })}
  >
    Red
  </button>
  <button
    type="button"
    class="button"
    disabled={!hasText}
    onclick={() => annotation.selection.update({ fontSize: large ? 16 : 24 })}
  >
    {large ? '16 pt' : '24 pt'}
  </button>
  <p class="hint">Ctrl or Cmd with B, I or U works while you type</p>
</div>
```

**`SelectRichText.svelte`**

```svelte
<!-- On load: the document's rich text box, selected. -->
<script lang="ts">
  import { untrack } from 'svelte';
  import { useStage } from '@embedpdf/svelte/stage';
  import { useAnnotation, useAnnotationState } from '@embedpdf/svelte/annotation';

  const annotation = useAnnotation();
  const stage = useStage();
  const ready = useAnnotationState((state) => state.status === 'ready');

  let done = false;
  $effect(() => {
    if (!ready.current || done) return;
    done = true;
    untrack(() => {
      const richText = annotation.list().find((each) => each.nm === 'rich-text');
      if (!richText) return;
      annotation.selection.set([richText.ref]);
      stage.reveal(richText.page, { rect: richText.rect });
    });
  });
</script>
```

## How it's stored

A text box has a **body style**, which is how its text looks by default, and **paragraphs** of
**runs**. A run is a piece of text with the styles it changes, and nothing else: a bold word in a
regular box is one run with `{ weight: 700 }`.

```ts
annotation.richText;
// {
//   body: { family: 'Helvetica', size: 12, color: '#000000' },
//   paragraphs: [{ runs: [{ text: 'Paid ' }, { text: 'in full', style: { weight: 700 } }] }],
// }
```

The engine writes it the way Acrobat does, so the formatting is the same there. The engine docs
have the full model: [Free text](https://www.cloudpdf.com/docs/engine/annotations/free-text#rich-text).

## Format from your toolbar

The same calls work while someone types in a box and when boxes are only selected:

```ts
await annotation.selection.update({ bold: true });
await annotation.selection.update({ fontSize: 24, fontColor: '#c00000' });
await annotation.text.toggleFormat('italic'); // italic on, or off if all of it already is
```

Where a change lands depends on what's selected:

- **Words selected while typing**: the change styles those words.
- **A caret while typing**: the change is for the text typed next, as in a word processor. Nothing
  changes until someone types, and moving the caret drops it.
- **Otherwise**: it styles all the text of every selected box. A word with its own style follows
  the change: turn the box red, and a blue word turns red too.

A property that isn't about text, such as opacity or the border, always applies to the whole box.

To show the state in your toolbar, [`useAnnotationProperties()`](../annotations/selecting#a-style-panel)
reports what the next change would style:

- **Words selected**: `values.bold` is true when every selected word is bold, and `mixed` lists what
  the words disagree on.
- **A caret**: the style of the text before it (at the start of a line, the text after it), with the
  changes made at the caret since.
- **A selected box**: all of its text, the way selected words read.

Text boxes also give you `textFormat` properties, so a style panel shows the bold, italic and
underline buttons without extra code.

> The engine's own update keeps the PDF's rule: `fontColor`, `fontSize` and `fontFamily` change the
> box's body style, and a word with its own style keeps it. See
> [Free text](https://www.cloudpdf.com/docs/engine/annotations/free-text#rich-text).

## When it's saved

Typing is written after a short pause, at every format change, and when editing ends, so the engine
never gets half a word. `documents.download()` waits for the last write, so the file you download has
everything the user typed.

## Draw your own text box

The annotation layer's text box already edits rich text. If you [draw text boxes
yourself](../annotations/custom-rendering), `useRichTextEditor()` makes your element the editor:
it draws the runs, turns typing back into runs, keeps the caret when the style changes, and handles
pasting, input methods and the format shortcuts:

**`branded-text-box.svelte`**

```svelte
<script lang="ts">
  import { useRichTextEditor, type AnnotationRendererProps } from '@embedpdf/svelte/annotation';

  let { annotation }: AnnotationRendererProps = $props();

  const editor = useRichTextEditor(() => annotation);
</script>

<!-- The box fills its frame (`width` and `height` 100% in your CSS) and draws it; the editor
     inside fills the box. -->
<div class={['text-box', editor.editing && 'editing']}>
  <div {@attach editor.attach} style={editor.style}></div>
</div>
```

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { SelectionLayer, selectionPlugin } from '@embedpdf/svelte/selection';
  import { interactionPlugin } from '@embedpdf/svelte/interaction';
  import {
    AnnotationLayer,
    annotationPlugin,
    type AnnotationRenderer,
  } from '@embedpdf/svelte/annotation';
  import { cloudEngine } from '@cloudpdf/engine';
  import SelectTextBox from './SelectTextBox.svelte';
  import BrandedTextBox from './BrandedTextBox.svelte';
  import FormatButtons from './FormatButtons.svelte';

  import '../branded-text-box.css';

  const engine = cloudEngine();
  const plugins = [
    stagePlugin(),
    renderPlugin(),
    interactionPlugin(),
    selectionPlugin(),
    annotationPlugin(),
  ];

  const notice: OpenInput = { kind: 'grant', grant: 'shr_LfeeUer1ogBI9mz8eYkWXa0d' };

  const RENDERERS: AnnotationRenderer[] = [
    { for: (annotation) => annotation.subtype === 'free-text', component: BrandedTextBox },
  ];
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: notice }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <SelectTextBox />
    <FormatButtons />
    <Stage class="stage">
      <RenderLayer />
      <SelectionLayer />
      <AnnotationLayer renderers={RENDERERS} />
    </Stage>
  </DocumentGate>
</Viewer>
```

**`branded-text-box.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --text: #3b4b7a;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
  --brand: #7c3aed;
  --brand-tint: #f5f0ff;
}

/* The controls, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 12px;
  cursor: pointer;
  white-space: nowrap;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:active {
  transform: translateY(1px);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

/* Each format button shows its own format */
.bold {
  font-weight: 800;
}

.italic {
  font-style: italic;
}

.underline {
  text-decoration: underline;
}

/* Your own text box */
.text-box {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  padding: 6px 10px;
  overflow: hidden;
  background: var(--brand-tint);
  border-left: 4px solid var(--brand);
  border-radius: 4px 10px 10px 4px;
  box-shadow: 0 6px 16px -10px rgb(76 29 149 / 0.45);
}

.text-box.editing {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

**`BrandedTextBox.svelte`**

```svelte
<!--
  Your own text box, in your app's look: it fills its frame, which the layer places and turns like
  the text box. The element inside is the editor and fills the box, so the box draws the fill,
  border and padding.
-->
<script lang="ts">
  import { useRichTextEditor, type AnnotationRendererProps } from '@embedpdf/svelte/annotation';

  let { annotation }: AnnotationRendererProps = $props();

  const editor = useRichTextEditor(() => annotation);
</script>

<div class={['text-box', editor.editing && 'editing']}>
  <div {@attach editor.attach} style={editor.style}></div>
</div>
```

**`FormatButtons.svelte`**

```svelte
<!-- The formatting calls work on your element as they do on the built-in one. -->
<script lang="ts">
  import { useAnnotation, useAnnotationState } from '@embedpdf/svelte/annotation';

  const FORMATS = ['bold', 'italic', 'underline'] as const;

  const annotation = useAnnotation();
  const hasText = useAnnotationState((state) =>
    state.selected.some((selected) => selected.subtype === 'free-text'),
  );
</script>

<div class="toolbar">
  {#each FORMATS as format (format)}
    <button
      type="button"
      class="button {format}"
      disabled={!hasText.current}
      onclick={() => annotation.text.toggleFormat(format)}
    >
      {format[0]!.toUpperCase()}
    </button>
  {/each}
</div>
```

**`SelectTextBox.svelte`**

```svelte
<!-- On load: the document's text box, selected, drawn by your component. -->
<script lang="ts">
  import { untrack } from 'svelte';
  import { useStage } from '@embedpdf/svelte/stage';
  import { useAnnotation, useAnnotationState } from '@embedpdf/svelte/annotation';

  const annotation = useAnnotation();
  const stage = useStage();
  const ready = useAnnotationState((state) => state.status === 'ready');

  let done = false;
  $effect(() => {
    if (!ready.current || done) return;
    done = true;
    untrack(() => {
      const textBox = annotation.list().find((each) => each.nm === 'text-box');
      if (!textBox) return;
      annotation.selection.set([textBox.ref]);
      stage.reveal(textBox.page, { rect: textBox.rect });
    });
  });
</script>
```

> **Try it:** double-click the box and type. The buttons format the selected words, as on the
> built-in box.

`editor.style` is the box's body style as a CSS string: font, size, color and alignment. It also makes the element fill its parent: the
text is laid out at 16 px or more and drawn back at the box's size, so iOS doesn't zoom the page in
when the box takes focus. Draw the box's fill, border and padding on the parent, as above; anything
on the element itself would scale with the text. The formatting calls above work on your element as
they do on the built-in one.
